From ee14de0435329f74b2f9f0fc713d49a9c147334e Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Mon, 12 Jan 2026 10:26:48 +0800 Subject: [PATCH] Add configuration and synchronization logic for Excel to SQL Server data processing --- CLAUDE.md | 196 +++++++++++++++++++++++++++++ etl_manager.py | 319 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 +- update_config.py | 154 +++++++++++++++++++++++ 4 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 etl_manager.py create mode 100644 update_config.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6f1b2ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,196 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a data synchronization system (BLD_sync) that extracts data from: +- **Microsoft Access databases** (.accdb) located on network shares +- **Excel files** (.xlsm) containing production execution cards and contract data + +And synchronizes it to: +- **Microsoft SQL Server** (CompanyDB) with multiple schemas organized by data type + +The system supports both full initialization sync and incremental sync driven by a change log table. + +## Architecture + +### Data Flow + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Network File Sources │ +│ Access DBs (\\192.168.110.114\生产进度表\) │ +│ Excel Files (\\192.168.110.113\生产执行卡\) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Sync Scripts │ +│ • etl_manager.py - Main ETL orchestrator (primary) │ +│ • sync_excel_to_sql.py - Excel to SQL sync (legacy/alternative) │ +│ • migration.py - Legacy customer product type migration │ +│ • run_incremental_sync.py - Change log driven incremental sync │ +│ • init_full_sync.py - Full table truncation/reload │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ SQL Server (CompanyDB) │ +│ Schemas: productionContractData, productWarehousing, │ +│ workshopOne/Two/Three, machining, contractPlanning, │ +│ inspectionRecords, partsWarehouse, etc. │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +**etl_manager.py** - Primary ETL orchestrator +- `DataSynchronizer` class manages Excel to SQL sync +- Supports incremental sync via file modification time comparison +- Caches Excel files locally in `excel_cache/` directory +- Uses SQLAlchemy with `fast_executemany=True` for bulk operations +- Generates `contractData` table via MERGE statement from `executionCardData` + +**config.py** - Central configuration +- `SYNC_MAPPING`: Nested dict mapping Access files → tables → SQL targets +- `EXCEL_CONFIGS`: List of Excel file configs with sheet names and field mappings +- `TABLE_SCHEMA`: Column type definitions for data cleaning +- `NTFY_CONFIG`: Push notification settings +- `LOG_TABLE_CONFIG`: Change log table schema for incremental sync + +**db_utils.py** - Database connection utilities +- `get_sql_conn()`: SQL Server connection via pyodbc +- `get_access_conn()`: Access database connection +- `fmt_table()`: Safe table name formatting `[schema].[table]` +- `generate_insert_sql()`: Dynamic INSERT statement generation + +**ntfy_utils.py** - Push notifications +- Sends alerts to ntfy server on errors/completion +- Uses Bearer token authentication + +**run_incremental_sync.py** - Log-driven incremental sync +- Polls `TableChangeLog` table for unsynced records (`Synced=0`) +- Queries by `TableAddress` to match configured file paths +- Processes deletions and insertions in batches +- Long-running service with configurable polling interval + +**init_full_sync.py** - Full table reload +- Truncates target tables and reloads all data from Access +- Handles IDENTITY_INSERT ON/OFF for tables with identity columns +- Progress logging with row counts and throughput metrics + +## Common Development Tasks + +### Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### Run Full Initial Sync (Reload All Data) + +```bash +python init_full_sync.py +``` + +### Run Excel to SQL Sync (Standard) + +```bash +python etl_manager.py +``` + +Force sync all files (ignore modification times): +```bash +python etl_manager.py --force +``` + +### Run Incremental Sync Service (Change Log Driven) + +```bash +python run_incremental_sync.py +``` + +### Run Legacy Migration Script + +```bash +python migration.py +``` + +Edit `FORCE_UPDATE = True` in `migration.py` to force full refresh. + +## Configuration Management + +**Main Config File**: `config.py` +- Contains all database credentials, file paths, and mapping configurations +- Modify `SYNC_MAPPING` to add new Access files/tables +- Modify `EXCEL_CONFIGS` for new Excel sources + +**Alternative Config**: `update_config.py` +- Used by `sync_excel_to_sql.py` +- Similar structure but different variable names +- Contains `EXECUTION_CARD_FIELDS`, `CONTRACT_DATA_MAPPING` + +## Important Implementation Details + +### SQL Server Connection +- Uses ODBC Driver 18 for SQL Server +- Requires `TrustServerCertificate=yes` due to self-signed cert +- SQLAlchemy URL: `mssql+pyodbc:///?odbc_connect=...` +- Always enable `fast_executemany=True` for bulk operations + +### Access Database Connection +- Driver: `{Microsoft Access Driver (*.mdb, *.accdb)}` +- Direct file path connection via pyodbc + +### Data Cleaning +- Integer fields: `pd.to_numeric().round().astype('Int64')` +- String fields: Truncate to max length, replace empty with None +- Date fields: Convert to `date()` objects, None for NaT +- Duplicate removal: Based on primary key (usually `ID` or `总排号`) + +### Identity Column Handling +Tables with identity columns require: +```sql +SET IDENTITY_INSERT [schema].[table] ON +-- perform inserts +SET IDENTITY_INSERT [schema].[table] OFF +``` + +See `has_identity_column()` in `init_full_sync.py` for detection logic. + +### File Path Matching in Incremental Sync +The change log table stores paths in VBA format: +- Network: `;DATABASE=\\server\share\file.accdb` +- Local: `LOCAL:\path\to\file.accdb` or `LOCAL=\path\to\file.accdb` + +The code constructs multiple match patterns for robust matching. + +## Database Schema Organization + +SQL Server schemas by function: +- `warehouseOutbound` - Execution card data, contract data, customer product types +- `productionContractData` - Contract data by year (25年/26年压力表/温度计) +- `productWarehousing` - Finished product inspection/warehousing records +- `workshopOne/Two/Three` - Workshop production records +- `machining` - Machining process records (膜片焊接, 喷涂寄出, etc.) +- `contractPlanning` - Work order assignment records +- `inspectionRecords` - Quality inspection records +- `partsWarehouse` - Component inventory records +- `thermometerRecord` - Thermometer calibration/testing records +- `solderingData` - Soldering operation records +- `TIGWelding` - TIG welding records +- `executionCardIssuanceRecord` - Execution card issuance records + +## Notifications + +The system uses [ntfy](https://ntfy.sh/) for push notifications: +- Configured in `NTFY_CONFIG` within `config.py` +- Sends on: errors, critical failures, task completion +- Authenticated via Bearer token + +## Logging + +- File logs: `log/` directory with timestamp rotation +- Console output: With emoji prefixes for status (✅ ❌ ⚠️ 🔄) +- Incremental sync: Uses `TimedRotatingFileHandler` for daily log files diff --git a/etl_manager.py b/etl_manager.py new file mode 100644 index 0000000..26e8e6b --- /dev/null +++ b/etl_manager.py @@ -0,0 +1,319 @@ +import os +import sys +import shutil +import logging +import argparse +import datetime +import urllib.parse +import warnings +import pandas as pd +import numpy as np +from sqlalchemy import create_engine, text +from sqlalchemy.engine import URL +from sqlalchemy.types import NVARCHAR, Integer, Date + +# 导入配置 +import update_config as config + +# ================= 抑制 openpyxl 的数据验证警告 ================= +warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl') + +# ================= 日志配置 ================= +# 配置控制台输出使用 UTF-8 编码,确保中文正确显示 +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + console_handler, + logging.FileHandler("sync_log.txt", encoding='utf-8') + ] +) +logger = logging.getLogger(__name__) + +class DataSynchronizer: + def __init__(self, force_sync=False): + self.force_sync = force_sync + self.engine = self._get_db_connection() + self.cache_dir = config.CACHE_DIR + + if not os.path.exists(self.cache_dir): + os.makedirs(self.cache_dir) + + def _get_db_connection(self): + connection_string = ( + f"DRIVER={{{config.DB_CONFIG['driver']}}};" + f"SERVER={config.DB_CONFIG['server']};" + f"DATABASE={config.DB_CONFIG['database']};" + f"UID={config.DB_CONFIG['username']};" + f"PWD={config.DB_CONFIG['password']};" + f"TrustServerCertificate={config.DB_CONFIG.get('TrustServerCertificate', 'no')};" + ) + connection_url = URL.create("mssql+pyodbc", query={"odbc_connect": connection_string}) + return create_engine(connection_url, fast_executemany=True) + + def _should_process_file(self, remote_path, local_path): + if self.force_sync: + return True, "强制同步" + + if not os.path.exists(local_path): + return True, "缓存不存在" + + try: + remote_mtime = os.path.getmtime(remote_path) + local_mtime = os.path.getmtime(local_path) + if remote_mtime > local_mtime + 1: + return True, f"源文件更新" + except OSError as e: + logger.error(f"无法访问源文件: {remote_path}, Error: {e}") + return False, "源文件无法访问" + + return False, "文件未变更" + + def _clean_dataframe(self, df, contract_year): + """主表数据清洗与验证""" + # 1. 设置合同年份 + df['合同年份'] = contract_year + + # 2. 移除总排号为空的行 + if '总排号' in df.columns: + df = df.dropna(subset=['总排号']) + df = df[df['总排号'].astype(str).str.strip() != ''] + else: + logger.error("数据源中找不到映射后的[总排号]列,跳过此 sheet") + return None, None + + # ★ 新增:去除重复的总排号(保留第一条) + if '总排号' in df.columns: + df['总排号'] = df['总排号'].astype(str).str.strip() + duplicates = df[df.duplicated(subset=['总排号'], keep='first')] + if not duplicates.empty: + logger.warning(f"发现 {len(duplicates)} 条重复的总排号,已自动去重。重复的总排号: {duplicates['总排号'].tolist()[:10]}") + df = df.drop_duplicates(subset=['总排号'], keep='first') + + # 3. 补全列 + for col in config.TABLE_SCHEMA.keys(): + if col not in df.columns: + df[col] = None + + # 用于存储每一列的 SQL 类型 + dtype_dict = {} + + # 4. 字段清洗 + for col, rules in config.TABLE_SCHEMA.items(): + if col not in df.columns: + continue + + if rules['type'] == 'int': + # ★ 修改:先转换为数值,然后四舍五入到整数 + df[col] = pd.to_numeric(df[col], errors='coerce') + # 将浮点数四舍五入为整数(处理如 123.5 这样的值) + df[col] = df[col].round(0) + # 转换为可空整数类型 + df[col] = df[col].astype('Int64') + # 将 NaN 替换为 None + df[col] = df[col].replace({pd.NA: None}) + dtype_dict[col] = Integer() + + elif rules['type'] == 'date': + df[col] = pd.to_datetime(df[col], errors='coerce') + df[col] = df[col].apply(lambda x: x.date() if pd.notnull(x) else None) + dtype_dict[col] = Date() + + elif rules['type'] == 'str': + # 先转换为字符串 + df[col] = df[col].fillna('').astype(str) + # 替换各种空值表示 + df[col] = df[col].replace({'nan': '', 'None': '', '': ''}) + + # 强制截断 + max_len = rules.get('max_len', 255) + df[col] = df[col].str.slice(0, max_len) + # 将空字符串转为 None + df[col] = df[col].replace('', None) + + dtype_dict[col] = NVARCHAR(max_len) + + final_cols = list(config.TABLE_SCHEMA.keys()) + + return df[final_cols], dtype_dict + + def _sync_to_db(self, df, dtype_dict): + """同步主表数据 - 使用更稳健的方法""" + if df is None or df.empty: + return + + target_table = "[warehouseOutbound].[executionCardData]" + + with self.engine.connect() as conn: + existing_ids = pd.read_sql(f"SELECT [总排号] FROM {target_table}", conn) + + existing_id_set = set(existing_ids['总排号'].astype(str)) + df['总排号'] = df['总排号'].astype(str).str.strip() + + df_update = df[df['总排号'].isin(existing_id_set)].copy() + df_insert = df[~df['总排号'].isin(existing_id_set)].copy() + + logger.info(f"分析结果: 需插入 {len(df_insert)} 条, 需更新 {len(df_update)} 条") + + # 1. 插入新数据 + if not df_insert.empty: + logger.info("正在执行批量插入...") + df_insert.to_sql('executionCardData', self.engine, schema='warehouseOutbound', + if_exists='append', index=False, chunksize=config.BATCH_SIZE, + dtype=dtype_dict) + logger.info("批量插入完成。") + + # 2. 更新现有数据 - 改用逐条或小批量 UPDATE + if not df_update.empty: + logger.info("正在执行批量更新...") + + cols = [c for c in df.columns if c != '总排号'] + set_clause = ", ".join([f"[{c}] = :{c}" for c in cols]) + update_sql = f""" + UPDATE [warehouseOutbound].[executionCardData] + SET {set_clause} + WHERE [总排号] = :总排号 + """ + + with self.engine.begin() as conn: + batch_size = 1000 + total_rows = len(df_update) + update_count = 0 + + for i in range(0, total_rows, batch_size): + batch = df_update.iloc[i:i+batch_size] + records = batch.to_dict('records') + + result = conn.execute(text(update_sql), records) + update_count += result.rowcount + + if (i + batch_size) % 5000 == 0: + logger.info(f"已更新 {i + batch_size}/{total_rows} 条记录...") + + # 修复 SQL Server executemany 返回负数 rowcount 的问题 + affected_rows = abs(update_count) if update_count < 0 else total_rows + logger.info(f"批量更新完成,共影响 {affected_rows} 行。") + + def process_excel_files(self): + for cfg in config.EXCEL_CONFIGS: + remote_path = cfg['file_path'] + filename = os.path.basename(remote_path) + local_path = os.path.join(self.cache_dir, filename) + + should_sync, reason = self._should_process_file(remote_path, local_path) + + if should_sync: + logger.info(f"开始处理文件: {filename} ({reason})") + try: + # 复制文件到本地缓存(只复制一次) + if os.path.exists(remote_path): + shutil.copy2(remote_path, local_path) + + # 遍历该文件的所有指定 sheet + for sheet_name in cfg['sheet_names']: + logger.info(f" → 处理工作表: {sheet_name} (合同年份: {cfg['contract_year']})") + try: + df = pd.read_excel(local_path, sheet_name=sheet_name, header=0, engine='openpyxl') + df.columns = [str(c).strip() for c in df.columns] + df.rename(columns=cfg['field_mapping'], inplace=True) + + cleaned_df, dtype_mapping = self._clean_dataframe(df, cfg['contract_year']) + + if cleaned_df is not None: + self._sync_to_db(cleaned_df, dtype_mapping) + logger.info(f" 工作表 {sheet_name} 同步成功。") + else: + logger.warning(f" 工作表 {sheet_name} 清洗失败,跳过。") + except Exception as e: + logger.error(f" 处理工作表 {sheet_name} 时发生错误: {str(e)}", exc_info=True) + + logger.info(f"文件 {filename} 所有工作表处理完成。") + + except Exception as e: + logger.error(f"处理文件 {filename} 时发生错误: {str(e)}", exc_info=True) + else: + logger.info(f"跳过文件: {filename} ({reason})") + + def generate_contract_data(self): + logger.info("开始生成/更新 contractData 表...") + + merge_sql = """ + WITH SourceData AS ( + SELECT + CAST(ISNULL([合同年份], '') AS NVARCHAR(10)) AS [合同年份], + CAST(ISNULL([车间号], '') AS NVARCHAR(20)) AS [车间号], + CAST(ISNULL([工令号], '') AS NVARCHAR(200)) AS [工令号], + CAST([订单号] AS NVARCHAR(150)) AS [订单号], + CAST([客户名称] AS NVARCHAR(200)) AS [客户名称], + CAST([产品名称] AS NVARCHAR(200)) AS [产品型号], + CAST([量程] AS NVARCHAR(150)) AS [量程], + TRY_CAST([数量] AS INT) AS [数量], + CAST(NULL AS INT) AS [单价], + TRY_CAST([序号] AS INT) AS [ID], + CAST([位号] AS NVARCHAR(500)) AS [位号], + ROW_NUMBER() OVER ( + PARTITION BY [合同年份], [车间号], [工令号] + ORDER BY [总排号] DESC + ) as rn + FROM [warehouseOutbound].[executionCardData] + WHERE + [车间号] IS NOT NULL AND [车间号] <> '' + AND [工令号] IS NOT NULL AND [工令号] <> '' + ) + + MERGE INTO [warehouseOutbound].[contractData] AS Target + USING (SELECT * FROM SourceData WHERE rn = 1) AS Source + ON ( + Target.[合同年份] = Source.[合同年份] + AND Target.[车间号] = Source.[车间号] + AND Target.[工令号] = Source.[工令号] + ) + + WHEN MATCHED THEN + UPDATE SET + Target.[订单号] = Source.[订单号], + Target.[客户名称] = Source.[客户名称], + Target.[产品型号] = Source.[产品型号], + Target.[量程] = Source.[量程], + Target.[数量] = Source.[数量], + Target.[ID] = Source.[ID], + Target.[位号] = Source.[位号] + + WHEN NOT MATCHED BY TARGET THEN + INSERT ( + [合同年份], [车间号], [工令号], + [订单号], [客户名称], [产品型号], + [量程], [数量], [单价], [ID], [位号] + ) + VALUES ( + Source.[合同年份], Source.[车间号], Source.[工令号], + Source.[订单号], Source.[客户名称], Source.[产品型号], + Source.[量程], Source.[数量], Source.[单价], Source.[ID], Source.[位号] + ) + ; + """ + + try: + with self.engine.begin() as conn: + result = conn.execute(text(merge_sql)) + logger.info(f"ContractData 表同步完成 (SQL Server 内部处理)。rowcount: {result.rowcount}") + + except Exception as e: + logger.error(f"生成 ContractData 失败: {e}", exc_info=True) + +def main(): + parser = argparse.ArgumentParser(description="Excel数据同步至SQL Server") + parser.add_argument('--force', action='store_true', help='强制同步所有文件') + args = parser.parse_args() + + syncer = DataSynchronizer(force_sync=args.force) + logger.info("================= 任务开始 =================") + syncer.process_excel_files() + syncer.generate_contract_data() + logger.info("================= 任务结束 =================") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 57ad573..ae554ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ -pyodbc>=5.0.0 \ No newline at end of file +pyodbc>=5.0.0 +pandas>=1.5.0 +sqlalchemy>=2.0.0 +openpyxl>=3.0.0 \ No newline at end of file diff --git a/update_config.py b/update_config.py new file mode 100644 index 0000000..204a2fd --- /dev/null +++ b/update_config.py @@ -0,0 +1,154 @@ +import os + +# ================= 数据库配置 ================= +DB_CONFIG = { + "server": "192.168.110.114", + "database": "CompanyDB", + "username": "peng", + "password": "Cqbld123456.", + "driver": "ODBC Driver 18 for SQL Server", + "TrustServerCertificate": "yes" +} + +# ================= 全局配置 ================= +CACHE_DIR = os.path.join(os.getcwd(), "temp") +BATCH_SIZE = 5000 + +# ================= 字段清洗规则 ================= +# 基于 NVARCHAR (按字符数计算长度) +TABLE_SCHEMA = { + "合同年份": {"type": "str", "max_len": 10}, + "总排号": {"type": "str", "max_len": 50}, + "序号": {"type": "int"}, + "订单号": {"type": "str", "max_len": 150}, + "车间号": {"type": "str", "max_len": 20}, + "销售内部号": {"type": "str", "max_len": 50}, + "经办人": {"type": "str", "max_len": 20}, + "签订日期": {"type": "date"}, + "交货日期": {"type": "date"}, + "客户名称": {"type": "str", "max_len": 200}, + "产品名称": {"type": "str", "max_len": 200}, + "客户型号": {"type": "str", "max_len": 200}, + "选型型号": {"type": "str", "max_len": 200}, + "量程": {"type": "str", "max_len": 150}, + "数量": {"type": "int"}, + "备注2": {"type": "str", "max_len": 500}, + "备注1": {"type": "str", "max_len": 500}, + "位号": {"type": "str", "max_len": 500}, + "技术参数": {"type": "str", "max_len": 1000}, + "车间": {"type": "str", "max_len": 200}, + "工令号": {"type": "str", "max_len": 200}, + "接单日期": {"type": "date"}, + "新参数": {"type": "str", "max_len": 1000}, + "基本型号": {"type": "str", "max_len": 200}, + "公称外径": {"type": "str", "max_len": 200}, + "安装代码": {"type": "str", "max_len": 200}, + "设计形式": {"type": "str", "max_len": 200}, + "技术安装代码": {"type": "str", "max_len": 200}, + "隔膜类型": {"type": "str", "max_len": 200}, + "标准": {"type": "str", "max_len": 100}, + "隔膜大小": {"type": "str", "max_len": 200}, + "隔膜材质": {"type": "str", "max_len": 200}, + "膜片": {"type": "str", "max_len": 200}, + "膜片材质": {"type": "str", "max_len": 200}, + "CRM明细ID号": {"type": "str", "max_len": 200}, + "成品物料编码": {"type": "str", "max_len": 200}, + "工单号": {"type": "str", "max_len": 200}, + "盘号": {"type": "str", "max_len": 200}, + "编码": {"type": "str", "max_len": 200}, + "型批名称": {"type": "str", "max_len": 200}, + "型批型号": {"type": "str", "max_len": 200}, + "开票名称": {"type": "str", "max_len": 200}, + "开票型号": {"type": "str", "max_len": 200}, + "上数据时间": {"type": "date"}, + "生产代码1": {"type": "str", "max_len": 50}, + "生产代码2": {"type": "str", "max_len": 50} +} + +CONTRACT_MAPPING = { + "合同年份": "合同年份", + "车间号": "车间号", + "工令号": "工令号", + "订单号": "订单号", + "客户名称": "客户名称", + "产品型号": "选型型号", + "量程": "量程", + "数量": "数量", + "单价": None, + "ID": None, + "位号": "位号" +} + +EXCEL_CONFIGS = [ + { + "file_path": r"\\192.168.110.113\生产执行卡\往年生产执行卡\生产执行卡2022.xlsm", + "sheet_names": ["Sheet1"], + "contract_year": "2022", + "field_mapping": { + "产品型号": "选型型号", + "备注": "备注1", + "下单日期": "接单日期" + + } + }, + { + "file_path": r"\\192.168.110.113\生产执行卡\往年生产执行卡\生产执行卡2023(1-5月).xlsm", + "sheet_names": ["Sheet1"], + "contract_year": "2023", + "field_mapping": { + "产品型号": "选型型号", + "备注": "备注1", + "下单日期": "接单日期" + } + }, + { + "file_path": r"\\192.168.110.113\生产执行卡\往年生产执行卡\生产执行卡2023(6月-.xlsm", + "sheet_names": ["Sheet1"], + "contract_year": "2023", + "field_mapping": { + "产品型号": "选型型号", + "备注": "备注1", + "下单日期": "接单日期" + } + }, + { + "file_path": r"\\192.168.110.113\生产执行卡\往年生产执行卡\生产执行卡2024 6月.xlsm", + "sheet_names": ["重庆数据","北京数据"], + "contract_year": "2024", + "field_mapping": { + "产品型号": "选型型号", + "备注": "备注1", + "下单日期": "接单日期" + } + }, + { + "file_path": r"\\192.168.110.113\生产执行卡\往年生产执行卡\生产执行卡2024.xlsm", + "sheet_names": ["重庆数据","北京数据"], + "contract_year": "2024", + "field_mapping": { + "产品型号": "选型型号", + "备注": "备注1", + "下单日期": "接单日期" + } + }, + { + "file_path": r"\\192.168.110.113\生产执行卡\生产执行卡2025年.xlsm", + "sheet_names": ["重庆数据","北京数据"], + "contract_year": "2025", + "field_mapping": { + "产品型号": "选型型号", + "备注": "备注1", + "下单日期": "接单日期" + } + }, + { + "file_path": r"\\192.168.110.113\生产执行卡\生产执行卡2026年.xlsm", + "sheet_names": ["重庆数据","北京数据"], + "contract_year": "2026", + "field_mapping": { + "产品型号": "选型型号", + "备注": "备注1", + "下单日期": "接单日期" + } + } +] \ No newline at end of file