初始化项目:数据同步服务源码与文档
- src: config_loader / source_watcher / excel_parser / sync_writer / runner - 169 Excel 全量同步至 114 SQL Server + Access 生产库 - 新增 .gitignore(忽略 venv、日志、数据、含凭据的 config.yaml 等) - 新增 README.md(用途、安装、配置、运行模式、部署说明)
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# ===== Python 运行时 / 字节码 =====
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# ===== 日志与运行输出 =====
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# ===== 数据与同步产物(运行时生成,非源码) =====
|
||||
data/
|
||||
|
||||
# ===== 配置(可能含数据库凭据,勿提交;如需共享请改用 config.yaml.example) =====
|
||||
config/config.yaml
|
||||
|
||||
# ===== 环境变量 / 密钥 =====
|
||||
.env
|
||||
.env.*
|
||||
*.secret
|
||||
|
||||
# ===== 编辑器 / 系统垃圾 =====
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# ===== 本地 Agent 运行时状态(非项目源码) =====
|
||||
.claude/
|
||||
.workbuddy/
|
||||
|
||||
# ===== 构建 / 打包产物 =====
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ProcurementVisibilityHub_DataSync
|
||||
|
||||
采购执行情况即时数据同步服务。将 169 共享盘上的 Excel(`采购执行情况即时数据.xlsx`)同步写入两个目标库:
|
||||
|
||||
- **SQL Server**(114 / `CompanyDB`,schema `procurementVisibilityHub`,表 `请购执行`)
|
||||
- **Access 生产库**(`ProcurementVisibilityHub.accdb`,表 `procurementVisibilityHub_请购执行`)
|
||||
|
||||
采用**全量覆盖**策略:SQL Server 端 `TRUNCATE + INSERT`,Access 端 `DELETE + INSERT`,并写入统一的「导入时间」。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `config/config.yaml` | 运行配置(含 DB 凭据,**已被 .gitignore 忽略**) |
|
||||
| `src/config_loader.py` | 读取 YAML 配置、构造 ODBC 连接串 |
|
||||
| `src/source_watcher.py` | 源文件变化检测(mtime/size)+ 下载到 `data/` |
|
||||
| `src/excel_parser.py` | 解析 xlsx → 行字典,含字段映射与类型转换 |
|
||||
| `src/sync_writer.py` | 全量写入 SQL Server + Access |
|
||||
| `src/runner.py` | 主入口:服务循环 / 单次 / 本地联调 / dry-run |
|
||||
| `data/` | 源文件历史副本(运行时生成,已忽略) |
|
||||
| `logs/sync.log` | 运行日志(运行时生成,已忽略) |
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python **3.10+**
|
||||
- 目标主机需安装 ODBC 驱动:
|
||||
- **ODBC Driver 17 for SQL Server**(连 114 SQL Server)
|
||||
- **Microsoft Access Driver (*.mdb, *.accdb)**(连 Access 生产库)
|
||||
- 依赖见 `requirements.txt`:`PyYAML`、`openpyxl`、`pyodbc`
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 1. 创建并激活虚拟环境(项目内)
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\activate # Windows
|
||||
# source .venv/bin/activate # Linux/macOS
|
||||
|
||||
# 2. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. 准备配置(从模板复制后填入真实凭据)
|
||||
cp config/config.yaml.example config/config.yaml # 若无模板,直接新建 config/config.yaml
|
||||
```
|
||||
|
||||
> ⚠️ `config/config.yaml` 含数据库账号密码,**不纳入版本管理**。如需团队共享,请提交一份 `config/config.yaml.example`(占位凭据),真实配置留本地。
|
||||
|
||||
---
|
||||
|
||||
## 配置说明(config/config.yaml 关键字段)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `source.unc_path` | 源 Excel 的 UNC 路径(169 共享盘) |
|
||||
| `source.local_cache_dir` | 本地历史副本目录(默认 `data`) |
|
||||
| `scan.interval_seconds` | 服务循环扫描间隔(秒) |
|
||||
| `sql_server.*` | SQL Server 连接信息(server/port/database/user/password…) |
|
||||
| `access.*` | Access 库路径与表名(DBQ/table) |
|
||||
| `field_map` | Excel 列名 → 数据库字段名(仅列名不同者) |
|
||||
| `auto_fields` | 数据库自动生成字段:`id_field`(自增不写)、`import_time_field`(=同步时刻) |
|
||||
| `sync.strategy` | 同步策略,当前固定 `full` |
|
||||
| `sync.batch_size` | 批量插入大小(默认 1000) |
|
||||
| `logging` | 日志级别与目录 |
|
||||
|
||||
---
|
||||
|
||||
## 使用方式
|
||||
|
||||
```bash
|
||||
# 服务循环模式(默认,配合 NSSM 部署为 Windows 服务)
|
||||
python -m src.runner
|
||||
|
||||
# 单次运行后退出
|
||||
python -m src.runner --once
|
||||
|
||||
# 用本地文件作源(跳过 169 下载,便于联调)
|
||||
python -m src.runner --local X.xlsx
|
||||
|
||||
# 仅解析并打印,不写库
|
||||
python -m src.runner --local X.xlsx --dry-run
|
||||
|
||||
# 指定配置文件
|
||||
python -m src.runner --config config/config.yaml
|
||||
```
|
||||
|
||||
运行逻辑:检测源 Excel 是否变化(mtime/size),无变化则跳过;有变化则下载副本 → 解析 → 全量写入两个目标库。
|
||||
|
||||
---
|
||||
|
||||
## 运行流程
|
||||
|
||||
```
|
||||
169 共享 Excel
|
||||
│ source_watcher 检测变化 + 下载到 data/
|
||||
▼
|
||||
excel_parser 解析(字段映射 + 类型转换:float/datetime/int/text)
|
||||
▼
|
||||
sync_writer 全量写入
|
||||
├─ SQL Server:TRUNCATE + 分批 INSERT(fast_executemany,事务提交)
|
||||
└─ Access: DELETE + 分批 INSERT(autocommit)
|
||||
▼
|
||||
logs/sync.log 记录全过程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署(NSSM 服务)
|
||||
|
||||
以 Windows 服务常驻运行(主机 114),由 NSSM 拉起 `python -m src.runner` 进入循环模式。日志统一写入 `logs/sync.log`。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **连不上 SQL Server**:确认 ODBC Driver 17 已装、114:1433 可达、账号密码正确(`Encrypt`/`TrustServerCertificate` 与服务器端匹配)。
|
||||
- **Access 写入失败**:确认目标主机装了 Microsoft Access Driver,且 `access.dbq` 路径存在、有写权限。
|
||||
- **源无变化不执行**:属正常行为(基于 mtime/size 的增量跳过);想强制全量可用 `--local` 指定当前文件或删除 `data/.source_meta.json` 基准。
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
PyYAML>=6.0
|
||||
openpyxl>=3.1
|
||||
pyodbc>=5.0
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
29
src/config_loader.py
Normal file
29
src/config_loader.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""配置加载:从 YAML 读取全部参数,并构造 ODBC 连接串。"""
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_config(path: str = "config/config.yaml") -> dict:
|
||||
cfg_path = Path(path)
|
||||
if not cfg_path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {cfg_path}")
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def build_sql_connstr(cfg: dict) -> str:
|
||||
s = cfg["sql_server"]
|
||||
return (
|
||||
f"DRIVER={{{s['driver']}}};"
|
||||
f"SERVER={s['server']},{s['port']};"
|
||||
f"DATABASE={s['database']};"
|
||||
f"UID={s['user']};PWD={s['password']};"
|
||||
f"Encrypt={'yes' if s.get('encrypt') else 'no'};"
|
||||
f"TrustServerCertificate={'yes' if s.get('trust_server_certificate') else 'no'}"
|
||||
)
|
||||
|
||||
|
||||
def build_access_connstr(cfg: dict) -> str:
|
||||
a = cfg["access"]
|
||||
return f"DRIVER={{{a['driver']}}};DBQ={a['dbq']};"
|
||||
127
src/excel_parser.py
Normal file
127
src/excel_parser.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Excel 解析:读取 xlsx → 字段映射 → 类型转换,输出行字典。"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.utils.datetime import from_excel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 请购执行表字段类型(基于 SQL Server 结构;未列出者按 text 处理)
|
||||
FIELD_TYPES = {
|
||||
# float:数量 / 金额
|
||||
"主数量": "float", "到货主数量": "float", "未到货数量": "float",
|
||||
"订货主数量": "float", "订货单价": "float", "订货金额": "float",
|
||||
"退货主数量": "float", "入库主数量": "float", "退库主数量": "float",
|
||||
"补货主数量": "float", "未执行关闭数量": "float",
|
||||
# datetime:日期 / 时间
|
||||
"创建时间": "datetime", "请购日期": "datetime", "到货日期": "datetime",
|
||||
"需求日期": "datetime", "建议订货日期": "datetime", "计划到货日期": "datetime",
|
||||
"订单日期": "datetime", "入库日期": "datetime",
|
||||
# int
|
||||
"行号": "int",
|
||||
}
|
||||
|
||||
_DATE_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d")
|
||||
|
||||
|
||||
def _to_float(v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(str(v).strip())
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("float 转换失败: %r", v)
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return int(float(str(v).strip()))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("int 转换失败: %r", v)
|
||||
return None
|
||||
|
||||
|
||||
def _to_datetime(v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, datetime):
|
||||
return v
|
||||
if isinstance(v, (int, float)):
|
||||
try:
|
||||
return from_excel(v)
|
||||
except (ValueError, OverflowError):
|
||||
logger.warning("日期序列号转换失败: %r", v)
|
||||
return None
|
||||
s = str(v).strip().lstrip("'")
|
||||
if not s:
|
||||
return None
|
||||
for fmt in _DATE_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
logger.warning("日期解析失败: %r", v)
|
||||
return None
|
||||
|
||||
|
||||
def _to_text(v):
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
CONVERTERS = {
|
||||
"float": _to_float,
|
||||
"int": _to_int,
|
||||
"datetime": _to_datetime,
|
||||
"text": _to_text,
|
||||
}
|
||||
|
||||
|
||||
def parse_excel(path, field_map=None, field_types=None):
|
||||
"""解析 xlsx,返回 (records, fields)。
|
||||
|
||||
records: list[dict],键为数据库字段名(经 field_map 映射)。
|
||||
fields: list[str],数据库字段名顺序(去空去重)。
|
||||
"""
|
||||
field_map = field_map or {}
|
||||
field_types = field_types or FIELD_TYPES
|
||||
path = Path(path)
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
finally:
|
||||
wb.close()
|
||||
if not rows:
|
||||
return [], []
|
||||
|
||||
raw_header = [str(c).strip() if c is not None else "" for c in rows[0]]
|
||||
mapped_header = [field_map.get(h, h) for h in raw_header]
|
||||
|
||||
records = []
|
||||
for row in rows[1:]:
|
||||
if all(c is None or c == "" for c in row):
|
||||
continue
|
||||
rec = {}
|
||||
for idx, field in enumerate(mapped_header):
|
||||
if not field or idx >= len(row):
|
||||
continue
|
||||
raw = row[idx]
|
||||
rec[field] = CONVERTERS[field_types.get(field, "text")](raw)
|
||||
records.append(rec)
|
||||
|
||||
fields, seen = [], set()
|
||||
for f in mapped_header:
|
||||
if f and f not in seen:
|
||||
seen.add(f)
|
||||
fields.append(f)
|
||||
|
||||
logger.info("解析完成: %d 行, %d 字段", len(records), len(fields))
|
||||
return records, fields
|
||||
97
src/runner.py
Normal file
97
src/runner.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""同步服务主入口(NSSM 部署)。
|
||||
|
||||
用法:
|
||||
python -m src.runner # 服务循环模式(NSSM)
|
||||
python -m src.runner --once # 单次运行后退出
|
||||
python -m src.runner --local X.xlsx # 用本地文件作源(跳过 169 下载,联调用)
|
||||
python -m src.runner --local X.xlsx --dry-run # 只解析并打印,不写库
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .config_loader import load_config
|
||||
from .excel_parser import parse_excel
|
||||
from .sync_writer import sync_all, _build_insert
|
||||
from . import source_watcher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_logging(cfg):
|
||||
log_cfg = cfg.get("logging", {})
|
||||
level = getattr(logging, log_cfg.get("level", "INFO").upper(), logging.INFO)
|
||||
log_dir = Path(log_cfg.get("dir", "logs"))
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
fh = logging.FileHandler(log_dir / "sync.log", encoding="utf-8")
|
||||
fh.setFormatter(fmt)
|
||||
root.addHandler(fh)
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(fmt)
|
||||
root.addHandler(sh)
|
||||
|
||||
|
||||
def run_once(cfg, source_path=None, dry_run=False):
|
||||
if source_path:
|
||||
local_file = source_path
|
||||
logger.info("使用本地源: %s", local_file)
|
||||
else:
|
||||
unc = cfg["source"]["unc_path"]
|
||||
cache = cfg["source"]["local_cache_dir"]
|
||||
meta_path = os.path.join(cache, ".source_meta.json")
|
||||
if not source_watcher.has_changed(unc, meta_path):
|
||||
return
|
||||
local_file = source_watcher.download(unc, cache)
|
||||
source_watcher.save_baseline(meta_path, source_watcher.get_source_meta(unc))
|
||||
|
||||
records, fields = parse_excel(local_file, cfg.get("field_map", {}))
|
||||
|
||||
if dry_run:
|
||||
import_col = cfg.get("auto_fields", {}).get("import_time_field", "导入时间")
|
||||
s = cfg["sql_server"]
|
||||
sql_tbl = f"{s['schema']}.[{s['table']}]"
|
||||
logger.info("[dry-run] 字段数=%d 行数=%d", len(fields), len(records))
|
||||
logger.info("[dry-run] SQL INSERT: %s", _build_insert(sql_tbl, fields, import_col))
|
||||
for i, r in enumerate(records[:3]):
|
||||
logger.info("[dry-run] 样本%d: %s", i + 1, {k: r[k] for k in fields[:6]})
|
||||
return
|
||||
|
||||
sync_all(cfg, records, fields)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="采购执行情况即时数据 同步服务")
|
||||
parser.add_argument("--once", action="store_true", help="单次运行后退出")
|
||||
parser.add_argument("--local", metavar="PATH", help="用本地文件作源(跳过 169 下载)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只解析并打印,不写库")
|
||||
parser.add_argument("--config", default="config/config.yaml")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config(args.config)
|
||||
setup_logging(cfg)
|
||||
|
||||
if args.local or args.dry_run:
|
||||
run_once(cfg, source_path=args.local, dry_run=args.dry_run)
|
||||
return
|
||||
if args.once:
|
||||
run_once(cfg)
|
||||
return
|
||||
|
||||
interval = cfg["scan"]["interval_seconds"]
|
||||
logger.info("服务循环启动,扫描间隔 %s 秒", interval)
|
||||
while True:
|
||||
try:
|
||||
run_once(cfg)
|
||||
except Exception as e:
|
||||
logger.exception("同步周期失败: %s", e)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
53
src/source_watcher.py
Normal file
53
src/source_watcher.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""源文件变化检测与下载。"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_source_meta(path):
|
||||
st = os.stat(path)
|
||||
return {"mtime": st.st_mtime, "size": st.st_size}
|
||||
|
||||
|
||||
def load_baseline(meta_path):
|
||||
p = Path(meta_path)
|
||||
if not p.exists():
|
||||
return None
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_baseline(meta_path, meta):
|
||||
Path(meta_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f)
|
||||
|
||||
|
||||
def has_changed(path, meta_path):
|
||||
try:
|
||||
cur = get_source_meta(path)
|
||||
except OSError as e:
|
||||
logger.error("无法访问源文件 %s: %s", path, e)
|
||||
raise
|
||||
base = load_baseline(meta_path)
|
||||
if base is None:
|
||||
logger.info("无基准记录,视为有变化")
|
||||
return True
|
||||
changed = (cur["mtime"] != base["mtime"]) or (cur["size"] != base["size"])
|
||||
logger.info("源文件 %s", "有变化" if changed else "无变化")
|
||||
return changed
|
||||
|
||||
|
||||
def download(path, cache_dir):
|
||||
cache = Path(cache_dir)
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
dst = cache / f"{Path(path).stem}_{ts}.xlsx"
|
||||
shutil.copy2(path, str(dst))
|
||||
logger.info("已下载: %s -> %s", path, dst)
|
||||
return str(dst)
|
||||
87
src/sync_writer.py
Normal file
87
src/sync_writer.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""全量同步写入:SQL Server (TRUNCATE+INSERT) + Access (DELETE+INSERT)。"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pyodbc
|
||||
|
||||
from .config_loader import build_sql_connstr, build_access_connstr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_insert(table_expr, fields, import_col):
|
||||
"""构造 INSERT SQL。table_expr 为完整表表达式(含 schema/方括号)。"""
|
||||
cols = list(fields) + [import_col]
|
||||
col_list = ",".join(f"[{c}]" for c in cols)
|
||||
placeholders = ",".join(["?"] * len(cols))
|
||||
return f"INSERT INTO {table_expr} ({col_list}) VALUES ({placeholders})"
|
||||
|
||||
|
||||
def _to_rows(records, fields, import_time):
|
||||
return [tuple(r.get(c) for c in fields) + (import_time,) for r in records]
|
||||
|
||||
|
||||
def sync_sql_server(cfg, records, fields, import_time):
|
||||
if not records:
|
||||
logger.warning("SQL Server: 无数据,跳过")
|
||||
return
|
||||
s = cfg["sql_server"]
|
||||
table_expr = f"{s['schema']}.[{s['table']}]" # procurementVisibilityHub.[请购执行]
|
||||
import_col = cfg.get("auto_fields", {}).get("import_time_field", "导入时间")
|
||||
insert_sql = _build_insert(table_expr, fields, import_col)
|
||||
rows = _to_rows(records, fields, import_time)
|
||||
batch = cfg.get("sync", {}).get("batch_size", 1000)
|
||||
|
||||
cn = pyodbc.connect(build_sql_connstr(cfg), autocommit=False)
|
||||
try:
|
||||
cur = cn.cursor()
|
||||
logger.info("SQL Server: 清空 %s", table_expr)
|
||||
try:
|
||||
cur.execute(f"TRUNCATE TABLE {table_expr}")
|
||||
except pyodbc.Error as e:
|
||||
logger.warning("TRUNCATE 失败(%s),改用 DELETE", e)
|
||||
cur.execute(f"DELETE FROM {table_expr}")
|
||||
cur.fast_executemany = True
|
||||
for i in range(0, len(rows), batch):
|
||||
cur.executemany(insert_sql, rows[i:i + batch])
|
||||
logger.info("SQL Server: 已插入 %d/%d", min(i + batch, len(rows)), len(rows))
|
||||
cn.commit()
|
||||
logger.info("SQL Server: 完成,共 %d 行", len(rows))
|
||||
except Exception:
|
||||
cn.rollback()
|
||||
raise
|
||||
finally:
|
||||
cn.close()
|
||||
|
||||
|
||||
def sync_access(cfg, records, fields, import_time):
|
||||
if not records:
|
||||
logger.warning("Access: 无数据,跳过")
|
||||
return
|
||||
a = cfg["access"]
|
||||
table_expr = f"[{a['table']}]" # [procurementVisibilityHub_请购执行]
|
||||
import_col = cfg.get("auto_fields", {}).get("import_time_field", "导入时间")
|
||||
insert_sql = _build_insert(table_expr, fields, import_col)
|
||||
rows = _to_rows(records, fields, import_time)
|
||||
batch = cfg.get("sync", {}).get("batch_size", 1000)
|
||||
|
||||
cn = pyodbc.connect(build_access_connstr(cfg), autocommit=True)
|
||||
try:
|
||||
cur = cn.cursor()
|
||||
logger.info("Access: 清空 %s", table_expr)
|
||||
cur.execute(f"DELETE FROM {table_expr}")
|
||||
total = len(rows)
|
||||
for i in range(0, total, batch):
|
||||
cur.executemany(insert_sql, rows[i:i + batch])
|
||||
logger.info("Access: 已插入 %d/%d", min(i + batch, total), total)
|
||||
logger.info("Access: 完成,共 %d 行", total)
|
||||
finally:
|
||||
cn.close()
|
||||
|
||||
|
||||
def sync_all(cfg, records, fields):
|
||||
import_time = datetime.now().replace(microsecond=0)
|
||||
logger.info("=== 开始全量同步 导入时间=%s 数据=%d 行 ===", import_time, len(records))
|
||||
sync_sql_server(cfg, records, fields, import_time)
|
||||
sync_access(cfg, records, fields, import_time)
|
||||
logger.info("=== 同步完成 ===")
|
||||
Reference in New Issue
Block a user