"""同步服务主入口(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): unc = cfg["source"]["unc_path"] cache = cfg["source"]["local_cache_dir"] meta_path = os.path.join(cache, ".source_meta.json") if source_path: local_file = source_path logger.info("使用本地源: %s", local_file) else: if not source_watcher.has_changed(unc, meta_path): return local_file = source_watcher.download(unc, cache) 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) # 仅整轮(解析 + 写库)成功后才落基线, # 避免把瞬态/损坏源记为“已处理”(曾导致基线被记成 size=0)。 if not source_path: try: source_watcher.save_baseline(meta_path, source_watcher.get_source_meta(unc)) except OSError as e: logger.warning("保存基线失败(不影响本次同步): %s", e) 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()