Преглед изворни кода

docs: 新增双模式第三方对接方案/执行任务书/UAT任务书及配套文档、脚本与飞书MCP工具

- 双模式落地:AIDOP双模式架构与第三方对接现状基线、落地完整实施方案(甲DB直连/乙API拉取/回写)、第三方大模型执行任务书
- AIDOPB 租户 UAT 就绪与补数任务书;数据中台支撑层建设项目方案;S0-S2/S1 报价与交付对比表
- doc 下文档处理脚本(.py)、飞书 MCP 集成说明与 tools/feishu-drive-mcp 工具(示例配置为占位符,无凭据)
- 更新 项目待办清单、aidopdev_165 库容量与增量写入分析
skygu пре 2 месеци
родитељ
комит
eb8916cf13
41 измењених фајлова са 7087 додато и 10 уклоњено
  1. 36 0
      .cursor/mcp.feishu.example.json
  2. 467 0
      doc/_add_req_func_codes.py
  3. 114 0
      doc/_adjust_front_matter.py
  4. 246 0
      doc/_batch_doc_cover_update.py
  5. 698 0
      doc/_export_quote_doc_compare_excel.py
  6. 142 0
      doc/_extend_delivery_front_matter.py
  7. 417 0
      doc/_fix_delivery_docs.py
  8. 361 0
      doc/_insert_func_prototypes.py
  9. 110 0
      doc/_recover_from_transcript.py
  10. 211 0
      doc/_restore_cover_format.py
  11. 287 0
      doc/_unify_doc_format.py
  12. 243 0
      doc/_unify_version_records.py
  13. 83 0
      doc/_verify_s1_s4_extra_smoke.py
  14. 160 0
      doc/feishu/MCP集成说明.md
  15. BIN
      doc/plan/AI-DOP报价清单_20260628_模块三行扩展.xlsx
  16. 122 0
      doc/plan/AIDOPB租户UAT就绪与补数任务书.md
  17. 330 0
      doc/plan/AIDOP双模式对接执行任务书.md
  18. 230 0
      doc/plan/AIDOP双模式架构与第三方对接现状基线.md
  19. 379 0
      doc/plan/AIDOP双模式落地完整实施方案.md
  20. 306 0
      doc/plan/Ai-DOP数据中台支撑层建设项目方案.md
  21. BIN
      doc/plan/Ai-DOP模块工时&旧系统复用统计_20260621.xlsx
  22. BIN
      doc/plan/Ai-DOP模块旧系统复用工时统计_20260625.xlsx
  23. BIN
      doc/plan/Ai-DOP模块旧系统复用工时统计_20260625_增加分摊报价工时.xlsx
  24. BIN
      doc/plan/Ai-DOP模块旧系统复用统计矩阵.xlsx
  25. BIN
      doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算.xlsx
  26. BIN
      doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算_人工调整版.xlsx
  27. BIN
      doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算_人工调整版_新规则.xlsx
  28. BIN
      doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算_人工调整版_新规则_增加报价原始工时.xlsx
  29. BIN
      doc/plan/Ai-DOP模块旧系统复用统计矩阵_简版工时.xlsx
  30. 4 1
      doc/plan/Ai-DOP项目待办清单.md
  31. BIN
      doc/plan/S0-S2-报价与交付文档对比表.xlsx
  32. BIN
      doc/plan/S0-S2-报价与交付文档对比表_v2.xlsx
  33. 88 0
      doc/plan/S1/S1-报价与交付文档串联对比表.md
  34. BIN
      doc/plan/_quote_work_copy.xlsx
  35. 56 9
      doc/plan/aidopdev_165库容量与增量写入分析_20260716.md
  36. 304 0
      doc/交付文档综合要求.md
  37. 15 0
      tools/feishu-drive-mcp/.env.example
  38. 244 0
      tools/feishu-drive-mcp/feishu-client.js
  39. 1176 0
      tools/feishu-drive-mcp/package-lock.json
  40. 16 0
      tools/feishu-drive-mcp/package.json
  41. 242 0
      tools/feishu-drive-mcp/server.js

+ 36 - 0
.cursor/mcp.feishu.example.json

@@ -0,0 +1,36 @@
+{
+  "mcpServers": {
+    "feishu-drive": {
+      "command": "node",
+      "args": [
+        "D:\\Projects\\Ai-DOP\\SourceCode\\ZZYDOP\\tools\\feishu-drive-mcp\\server.js"
+      ],
+      "env": {
+        "FEISHU_APP_ID": "cli_你的AppID",
+        "FEISHU_APP_SECRET": "你的AppSecret",
+        "FEISHU_DOMAIN": "https://open.feishu.cn",
+        "FEISHU_DEFAULT_FOLDER_TOKEN": "Ks1RfygMblbsNEdB3JdcxHCnnFb",
+        "FEISHU_WORKSPACE_ROOT": "D:\\Projects\\Ai-DOP\\SourceCode\\ZZYDOP"
+      }
+    },
+    "feishu-openapi": {
+      "command": "npx",
+      "args": [
+        "-y",
+        "@larksuiteoapi/lark-mcp",
+        "mcp",
+        "-a",
+        "cli_你的AppID",
+        "-s",
+        "你的AppSecret",
+        "-d",
+        "https://open.feishu.cn",
+        "--oauth",
+        "--token-mode",
+        "user_access_token",
+        "-t",
+        "preset.doc.default,preset.drive.default"
+      ]
+    }
+  }
+}

+ 467 - 0
doc/_add_req_func_codes.py

@@ -0,0 +1,467 @@
+# -*- coding: utf-8 -*-
+"""Add REQ/FUNC codes from triple对照表 into existing requirement/blueprint docx."""
+from __future__ import annotations
+
+import re
+import shutil
+import tempfile
+from pathlib import Path
+
+import openpyxl
+from docx import Document
+
+BASE = Path(r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档")
+
+DOCS = {
+    "S0": {
+        "req": BASE / "S0" / "S0-基础主数据-业务需求描述-V0.5.docx",
+        "blue": BASE / "S0" / "S0-基础主数据建模-业务蓝图设计方案-V0.5.docx",
+        "triple": BASE / "S0" / "S0-三联对照表.xlsx",
+    },
+    "S1": {
+        "req": BASE / "S1" / "S1产销协同业务需求描述.docx",
+        "blue": BASE / "S1" / "S1产销协同模块蓝图设计方案.docx",
+        "triple": BASE / "S1" / "S1-三联对照表.xlsx",
+    },
+    "S2": {
+        "req": BASE / "S2" / "S2制造协同业务需求描述.docx",
+        "blue": BASE / "S2" / "S2制造协同模块蓝图设计方案.docx",
+        "triple": BASE / "S2" / "S2-三联对照表.xlsx",
+    },
+    "S3": {
+        "req": BASE / "S3" / "S3供应协同业务需求描述.docx",
+        "blue": BASE / "S3" / "S3供应协同模块蓝图设计方案.docx",
+        "triple": BASE / "S3" / "S3-三联对照表.xlsx",
+    },
+    "S4": {
+        "req": BASE / "S4" / "S4采购执行业务需求描述.docx",
+        "blue": BASE / "S4" / "S4采购执行模块蓝图设计方案.docx",
+        "triple": BASE / "S4" / "S4-三联对照表.xlsx",
+    },
+}
+
+# S3/S4 交付目录暂无 xlsx 时,与 aidopFuncCodes.ts 及 S1 编号规则对齐
+BUILTIN_TRIPLE: dict[str, list[tuple[str, str, str, str]]] = {
+    "S3": [
+        ("REQ-S3-001", "物料需求计划", "FUNC-S3-001", "物料需求计划"),
+        ("REQ-S3-002", "物料交货计划", "FUNC-S3-002", "物料交货计划"),
+        ("REQ-S3-003", "交货单异常记录", "FUNC-S3-003", "交货单异常记录"),
+        ("REQ-S3-004", "物料采购申请", "FUNC-S3-004", "物料采购申请"),
+        ("REQ-S3-005", "要货令", "FUNC-S3-005", "要货令"),
+        ("REQ-S3-006", "物料采购订单", "FUNC-S3-006", "物料采购订单"),
+        ("REQ-S3-007", "委外加工订单", "FUNC-S3-007", "委外加工订单"),
+        ("REQ-S3-008", "工序外协订单", "FUNC-S3-008", "工序外协订单"),
+        ("REQ-S3-009", "供应协同看板", "FUNC-S3-009", "供应协同看板"),
+        ("REQ-S3-010", "工单物料齐套上线看板", "FUNC-S3-010", "工单物料齐套上线看板"),
+        ("REQ-S3-011", "MDP运行监控", "FUNC-S3-011", "MDP运行监控"),
+        ("REQ-S3-012", "委外采购明细", "FUNC-S3-012", "委外采购明细"),
+    ],
+    "S4": [
+        ("REQ-S4-001", "供应商交货管理", "FUNC-S4-001", "供应商交货管理"),
+        ("REQ-S4-002", "供应商发货单", "FUNC-S4-002", "供应商发货单"),
+        ("REQ-S4-003", "采购退货单", "FUNC-S4-003", "采购退货单"),
+        ("REQ-S4-004", "IQC退货查询", "FUNC-S4-004", "IQC退货查询"),
+        ("REQ-S4-005", "采购执行看板主页", "FUNC-S4-005", "采购执行看板主页"),
+        ("REQ-S4-006", "供应商欠料看板", "FUNC-S4-006", "供应商欠料看板"),
+    ],
+}
+
+# Document title aliases -> triple 需求名称 key
+S0_ALIASES = {
+    "物料维护": "物料管理",
+    "客户信息维护": "客户管理",
+    "订单优先级配置": "订单优先级规则管理",
+    "合同评审周期": "合同评审周期规则管理",
+    "产品设计周期": "产品设计周期规则管理",
+    "订单评审周期": "订单评审周期规则管理",
+    "供应商维护": "供应商管理",
+    "货源清单": "采购分类与货源管理",
+    "品类采购前置期": "品类交货周期管理",
+    "物料计划周期": "物料计划周期管理",
+    "物料计划周期标准": "物料计划周期管理",
+    "生产线维护": "产线管理",
+    "生产线": "产线管理",
+    "工作中心": "工作中心管理",
+    "产线岗位维护": "产线工位管理",
+    "生产线物料(线边物料)": "产线物料管理",
+    "线边物料": "产线物料管理",
+    "人员技能维护": "人员技能分配",
+    "标准工序": "标准工序管理",
+    "标准工艺路线": "标准工艺路线管理",
+    "工序流转卡": "工艺流程卡管理",
+    "标准 BOM": "标准 BOM 管理",
+    "SOP 维护": "SOP 维护管理",
+    "SOP 文档维护": "SOP 维护管理",
+    "SOP 文件类型": "SOP 文件类型管理",
+    "前处理要素": "前置工序要素管理",
+    "物料前处理要素": "前置工序要素管理",
+    "前处理要素参数": "前置工序要素参数管理",
+    "生产要素参数": "生产要素参数管理",
+    "物料替代": "物料替代明细管理",
+    "物料替代关系": "物料替代明细管理",
+    "物料替代(制造入口)": "物料替代明细管理(制造域)",
+    "选择替代方案": "物料代替方案管理",
+    "选择替代方案(制造入口)": "物料代替方案管理",
+    "工单控制参数": "工单控制参数管理",
+    "订单排程周期": "订单排程周期管理",
+    "检验项目": "检验项目管理",
+    "检验方法": "检验方法管理",
+    "检验依据": "检验基准管理",
+    "检验标准": "检验标准管理",
+    "检验频率": "检验频次管理",
+    "检验仪器": "检验仪器管理",
+    "抽样方案": "抽样方案管理",
+    "原材料检验规范(IQC)": "来料检验规范(IQC)",
+    "过程检验规范(IPQC)": "过程检验规范(IPQC)",
+    "FQC 检验规范": "成品检验规范(OQC/FQC)",
+    "OQC 检验规范": "成品检验规范(OQC/FQC)",
+    "原材料白名单": "来料白名单管理",
+    "质量基础": "质量字典管理",
+    "质量基础字典": "质量字典管理",
+    "库位维护": "库位管理",
+    "货架列表": "库位货架管理",
+    "部门维护": "部门管理",
+    "雇员列表": "员工管理",
+    "物料职责维护": "员工工作职责管理",
+    "成本中心": "成本中心管理",
+    "零件包装规格": "物料包装规格管理",
+    "物料状态任务指派": "任务分配管理",
+    "人员技能": "人员技能管理",
+    "标签格式": "标签类型管理",
+    "单号类型": "批次类型管理",
+    "单号规则维护": "批次号控制管理",
+    "单号规则": "批次号控制管理",
+}
+
+S1_ALIASES = {}
+S2_ALIASES = {
+    "生产排程": "生产排程",
+    "排产异常记录": "排产异常记录",
+    "排产异常": "排产异常记录",
+    "排产数据维护": "排产异常记录",
+    "可执行日计划": "可执行日计划",
+    "产线工作日历管理": "产线工作日历管理",
+    "产线工作日历": "产线工作日历管理",
+    "产线休息时间管理": "产线休息时间管理",
+    "产线休息时间": "产线休息时间管理",
+    "产线节假日管理": "产线节假日管理",
+    "产线节假日": "产线节假日管理",
+    "产线加班管理": "产线加班管理",
+    "产线加班": "产线加班管理",
+    "工单执行进度看板": "工单执行进度看板",
+    "工单执行进度": "工单执行进度看板",
+    "工单执行追踪": "工单执行进度看板",
+    "S2制造协同看板": "S2制造协同看板(KPI)",
+    "制造协同看板与KPI": "S2制造协同看板(KPI)",
+    "制造协同看板": "S2制造协同看板(KPI)",
+    "作业计划": "可执行日计划",
+}
+
+S1_REQ_SECTION = {
+    "产品设计管理": "REQ-S1-001",
+    "合同评审管理": "REQ-S1-002",
+    "订单评审管理": "REQ-S1-003",
+    "订单交付管理": "REQ-S1-004",
+    "订单发货管理": "REQ-S1-005",
+    "工单下达": "REQ-S1-006",
+    "计划联动看板": "REQ-S1-007",
+    "需求明细核验": "REQ-S1-008",
+    "产销协同看板": "REQ-S1-009",
+}
+
+S1_FUNC_SECTION = {k: f"FUNC-S1-{i:03d}" for i, k in enumerate(
+    ["产品设计管理", "订单评审管理", "订单交付管理", "订单发货管理", "合同评审管理",
+     "计划联动看板", "需求明细核验", "工单下达", "产销协同看板"], 1)}
+
+# S1 blueprint chapter order differs from REQ order; map by title
+S1_FUNC_BY_TITLE = {
+    "产品设计管理": "FUNC-S1-001",
+    "订单评审管理": "FUNC-S1-003",
+    "订单交付管理": "FUNC-S1-004",
+    "订单发货管理": "FUNC-S1-005",
+    "合同评审管理": "FUNC-S1-002",
+    "计划联动看板": "FUNC-S1-007",
+    "需求明细核验": "FUNC-S1-008",
+    "工单下达": "FUNC-S1-006",
+    "产销协同看板": "FUNC-S1-009",
+}
+
+S3_REQ_SECTION = {name: req for req, name, _, _ in BUILTIN_TRIPLE["S3"]}
+S3_FUNC_BY_TITLE = {
+    title: func
+    for _, name, func, fname in BUILTIN_TRIPLE["S3"]
+    for title in (name, fname)
+}
+S4_REQ_SECTION = {name: req for req, name, _, _ in BUILTIN_TRIPLE["S4"]}
+S4_FUNC_BY_TITLE = {
+    title: func
+    for _, name, func, fname in BUILTIN_TRIPLE["S4"]
+    for title in (name, fname)
+}
+
+S3_ALIASES: dict[str, str] = {}
+S4_ALIASES: dict[str, str] = {}
+
+
+def load_triple_from_rows(rows: list[tuple[str, str, str, str]]) -> dict[str, tuple[str, str]]:
+    by_name: dict[str, tuple[str, str]] = {}
+    for req, req_name, func, func_name in rows:
+        by_name[req_name] = (req, func)
+        by_name[func_name] = (req, func)
+    return by_name
+
+
+def load_triple(path: Path, module: str) -> dict[str, tuple[str, str]]:
+    if module in BUILTIN_TRIPLE and not path.exists():
+        return load_triple_from_rows(BUILTIN_TRIPLE[module])
+    tmp = Path(tempfile.gettempdir()) / f"{module}_triple.xlsx"
+    shutil.copy2(path, tmp)
+    wb = openpyxl.load_workbook(tmp, data_only=True)
+    ws = wb.active
+    by_name: dict[str, tuple[str, str]] = {}
+    if module == "S2":
+        for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True):
+            if not row or len(row) < 5:
+                continue
+            func, req = str(row[3]).strip(), str(row[4]).strip()
+            if not req.startswith("REQ-"):
+                continue
+            menu = str(row[6] or "")
+            name = menu.split("→")[-1].strip() if "→" in menu else func
+            by_name[name] = (req, func)
+        # also map by standard func names from repo
+        names = [
+            "生产排程", "排产异常记录", "可执行日计划", "产线工作日历管理", "产线休息时间管理",
+            "产线节假日管理", "产线加班管理", "工单执行进度看板", "S2制造协同看板(KPI)",
+        ]
+        reqs = [f"REQ-S2-{i:03d}" for i in range(1, 10)]
+        funcs = [f"FUNC-S2-{i:03d}" for i in range(1, 10)]
+        for n, r, f in zip(names, reqs, funcs):
+            by_name.setdefault(n, (r, f))
+        wb.close()
+        return by_name
+
+    for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True):
+        if not row or not row[0]:
+            continue
+        req, req_name, func, func_name = [str(x).strip() for x in row[:4]]
+        by_name[req_name] = (req, func)
+        by_name[func_name] = (req, func)
+    wb.close()
+    return by_name
+
+
+def build_title_map(module: str, by_name: dict[str, tuple[str, str]]) -> dict[str, tuple[str, str]]:
+    aliases = {"S0": S0_ALIASES, "S1": S1_ALIASES, "S2": S2_ALIASES, "S3": S3_ALIASES, "S4": S4_ALIASES}[module]
+    title_map: dict[str, tuple[str, str]] = {}
+    for name, codes in by_name.items():
+        title_map[name] = codes
+    for alias, target in aliases.items():
+        if target in by_name:
+            title_map[alias] = by_name[target]
+        elif target in title_map:
+            title_map[alias] = title_map[target]
+    return title_map
+
+
+def normalize_title(text: str) -> str:
+    text = text.strip()
+    text = re.sub(r"^([一二三四五六七八九十]+)", "", text)
+    text = re.sub(r"^\d+(?:\.\d+)*\s+", "", text)
+    text = re.sub(r"\d+$", "", text).strip()
+    text = re.sub(r"\s*——.*$", "", text)
+    text = re.sub(r"\s*—\s*.*$", "", text)
+    text = re.sub(r"(FUNC-[^)]+)", "", text)
+    text = re.sub(r"(REQ-[^)]+)", "", text)
+    text = re.sub(r"\s*\(REQ-[^)]+\)", "", text)
+    text = re.sub(r"\s*\(FUNC-[^)]+\)", "", text)
+    return text.strip()
+
+
+def extract_heading_title(text: str) -> str | None:
+    text = text.strip()
+    if not text:
+        return None
+    if text in {"功能说明", "业务描述", "目录", "目录2"}:
+        return None
+    if re.match(r"^[一二三四五六七八九十]+、", text):
+        return None
+    if "  —  " in text or " — " in text:
+        return normalize_title(text.split("—", 1)[0])
+    if len(text) > 80:
+        return None
+    m = re.match(r"^([一二三四五六七八九十]+)(.+)$", text)
+    if m:
+        return normalize_title(m.group(1))
+    m = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)$", text)
+    if m:
+        return normalize_title(m.group(2))
+    if len(text) <= 40:
+        return normalize_title(text)
+    return None
+
+
+def has_code(text: str, code: str) -> bool:
+    return code in text
+
+
+def set_paragraph_text(paragraph, new_text: str) -> None:
+    if paragraph.runs:
+        paragraph.runs[0].text = new_text
+        for r in paragraph.runs[1:]:
+            r.text = ""
+    else:
+        paragraph.add_run(new_text)
+
+
+def strip_existing_codes(text: str) -> str:
+    return re.sub(r"((?:REQ|FUNC)-S\d+-\d+)", "", text)
+
+
+def append_code(paragraph, code: str) -> bool:
+    text = paragraph.text
+    if has_code(text, code):
+        return False
+    suffix = f"({code})"
+    set_paragraph_text(paragraph, text.rstrip() + suffix)
+    return True
+
+
+def match_codes(module: str, title: str, title_map: dict[str, tuple[str, str]], doc_kind: str) -> str | None:
+    if module == "S1" and doc_kind == "req":
+        return S1_REQ_SECTION.get(title)
+    if module == "S1" and doc_kind == "blue":
+        return S1_FUNC_BY_TITLE.get(title)
+    if module == "S3" and doc_kind == "req":
+        return S3_REQ_SECTION.get(title)
+    if module == "S3" and doc_kind == "blue":
+        return S3_FUNC_BY_TITLE.get(title)
+    if module == "S4" and doc_kind == "req":
+        return S4_REQ_SECTION.get(title)
+    if module == "S4" and doc_kind == "blue":
+        return S4_FUNC_BY_TITLE.get(title)
+    if title in title_map:
+        req, func = title_map[title]
+        return req if doc_kind == "req" else func
+    return None
+
+
+def iter_paragraphs(doc: Document):
+    for p in doc.paragraphs:
+        yield p
+    for table in doc.tables:
+        for row in table.rows:
+            for cell in row.cells:
+                for p in cell.paragraphs:
+                    yield p
+
+
+S2_BLUE_EXACT_ROWS = {
+    "生产排程": "FUNC-S2-001",
+    "排产异常记录": "FUNC-S2-002",
+    "排产数据维护": "FUNC-S2-002",
+    "可执行日计划": "FUNC-S2-003",
+    "日计划列表查询": "FUNC-S2-003",
+    "日计划下达": "FUNC-S2-003",
+    "产线工作日历管理": "FUNC-S2-004",
+    "产线休息时间管理": "FUNC-S2-005",
+    "产线节假日管理": "FUNC-S2-006",
+    "产线加班管理": "FUNC-S2-007",
+    "工单执行进度看板": "FUNC-S2-008",
+    "工单执行追踪": "FUNC-S2-008",
+}
+
+
+def s2_req_inline_match(text: str, key: str) -> bool:
+    if key == "排产异常":
+        return text.startswith("排产异常")
+    if key == "可执行日计划":
+        return text.startswith("展示面向车间产线的可执行日计划") or text.startswith('计划员/调度人员进入"可执行日计划"')
+    if key == "产线工作日历":
+        return text.startswith("产线工作日历")
+    if key == "产线休息时间":
+        return text.startswith("产线休息时间")
+    if key == "产线节假日":
+        return text.startswith("产线节假日")
+    if key == "产线加班":
+        return text.startswith("产线加班")
+    if key == "工单执行进度":
+        return text.startswith("工单执行进度") or text.startswith("管理层/计划员进入\"工单执行进度看板\"")
+    return False
+
+
+def process_doc(
+    path: Path,
+    module: str,
+    title_map: dict[str, tuple[str, str]],
+    doc_kind: str,
+    *,
+    refresh: bool = False,
+) -> int:
+    doc = Document(path)
+    changed = 0
+    if refresh:
+        for p in iter_paragraphs(doc):
+            stripped = strip_existing_codes(p.text)
+            if stripped != p.text:
+                set_paragraph_text(p, stripped)
+                changed += 1
+    for p in iter_paragraphs(doc):
+        text = p.text.strip()
+        if not text:
+            continue
+        if module == "S2" and doc_kind == "blue" and text in S2_BLUE_EXACT_ROWS:
+            if append_code(p, S2_BLUE_EXACT_ROWS[text]):
+                changed += 1
+            continue
+        title = extract_heading_title(p.text)
+        if not title:
+            continue
+        code = match_codes(module, title, title_map, doc_kind)
+        if not code:
+            continue
+        if append_code(p, code):
+            changed += 1
+    if module == "S2" and doc_kind == "req":
+        inline_keys = [
+            ("排产异常", "REQ-S2-002"),
+            ("可执行日计划", "REQ-S2-003"),
+            ("产线工作日历", "REQ-S2-004"),
+            ("产线休息时间", "REQ-S2-005"),
+            ("产线节假日", "REQ-S2-006"),
+            ("产线加班", "REQ-S2-007"),
+            ("工单执行进度", "REQ-S2-008"),
+        ]
+        for p in iter_paragraphs(doc):
+            t = p.text.strip()
+            if not t or len(t) > 200:
+                continue
+            for key, code in inline_keys:
+                if s2_req_inline_match(t, key) and not has_code(t, code):
+                    if append_code(p, code):
+                        changed += 1
+                    break
+    doc.save(path)
+    return changed
+
+
+def main():
+    import sys
+
+    refresh = "--refresh" in sys.argv
+    only = None
+    for arg in sys.argv[1:]:
+        if arg.startswith("--only="):
+            only = {x.strip().upper() for x in arg.split("=", 1)[1].split(",") if x.strip()}
+    modules = [m for m in DOCS if not only or m in only]
+    for module in modules:
+        cfg = DOCS[module]
+        by_name = load_triple(cfg["triple"], module)
+        title_map = build_title_map(module, by_name)
+        n1 = process_doc(cfg["req"], module, title_map, "req", refresh=refresh)
+        n2 = process_doc(cfg["blue"], module, title_map, "blue", refresh=refresh)
+        print(f"{module}: req +{n1}, blue +{n2}, mapped titles {len(title_map)}")
+
+
+if __name__ == "__main__":
+    main()

+ 114 - 0
doc/_adjust_front_matter.py

@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+"""Adjust front matter: drop 文档控制, rename 更改记录, page breaks."""
+from __future__ import annotations
+
+import shutil
+import tempfile
+from pathlib import Path
+
+from docx import Document
+from docx.enum.text import WD_BREAK
+from docx.oxml import OxmlElement
+from docx.text.paragraph import Paragraph
+
+BASE = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
+)
+
+
+def remove_paragraph(p: Paragraph) -> None:
+    p._element.getparent().remove(p._element)
+
+
+def clear_page_breaks(p: Paragraph) -> None:
+    for br in p._element.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}br"):
+        br.getparent().remove(br)
+
+
+def ensure_page_break_before(p: Paragraph) -> None:
+    clear_page_breaks(p)
+    text = p.text
+    p.text = ""
+    br_run = p.add_run()
+    br_run.add_break(WD_BREAK.PAGE)
+    if text:
+        p.add_run(text)
+
+
+def adjust_file(path: Path) -> None:
+    work = path
+    tmp_path = None
+    try:
+        doc = Document(path)
+    except PermissionError:
+        tmp_path = Path(tempfile.gettempdir()) / f"docfix_{path.name}"
+        shutil.copy2(path, tmp_path)
+        work = tmp_path
+        doc = Document(work)
+
+    removed_control = False
+    renamed = False
+    version_break = False
+    toc_break = False
+
+    for p in list(doc.paragraphs):
+        if p.text.strip() == "文档控制":
+            remove_paragraph(p)
+            removed_control = True
+
+    for p in doc.paragraphs:
+        if p.text.strip() == "更改记录":
+            p.text = "版本记录"
+            renamed = True
+            break
+
+    for p in doc.paragraphs:
+        if p.text.strip() == "版本记录":
+            ensure_page_break_before(p)
+            version_break = True
+            break
+
+    for p in doc.paragraphs:
+        if p.text.strip() == "目录":
+            ensure_page_break_before(p)
+            toc_break = True
+            break
+
+    if not renamed and not any(p.text.strip() == "版本记录" for p in doc.paragraphs):
+        raise RuntimeError(f"未找到「版本记录」: {path.name}")
+    if not version_break:
+        raise RuntimeError(f"未找到「版本记录」: {path.name}")
+    if not toc_break:
+        raise RuntimeError(f"未找到「目录」: {path.name}")
+
+    doc.save(work)
+    if tmp_path is not None:
+        try:
+            shutil.copy2(work, path)
+            tmp_path.unlink(missing_ok=True)
+        except PermissionError:
+            print(f"  !! {path.name} 被占用,已写入: {work}")
+            return
+    print(
+        f"{path.name}: 删文档控制={'Y' if removed_control else 'N'} "
+        f"版本记录分页=Y 目录分页=Y"
+    )
+
+
+def iter_delivery_docx():
+    for module in ["S0", "S1", "S2", "S3", "S4"]:
+        mod_dir = BASE / module
+        if not mod_dir.is_dir():
+            continue
+        for path in sorted(mod_dir.rglob("*.docx")):
+            if not path.name.startswith("~$"):
+                yield path
+
+
+def main() -> None:
+    for path in iter_delivery_docx():
+        adjust_file(path)
+
+
+if __name__ == "__main__":
+    main()

+ 246 - 0
doc/_batch_doc_cover_update.py

@@ -0,0 +1,246 @@
+# -*- coding: utf-8 -*-
+"""Unify system name and add version record tables in S0-S4 delivery docx."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from docx import Document
+from docx.enum.text import WD_BREAK
+from docx.oxml import OxmlElement
+from docx.shared import Pt
+from docx.text.paragraph import Paragraph
+
+BASE = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
+)
+OLD_NAMES = (
+    "Ai-DOP 智慧运营决策平台",
+    "Ai-DOP智慧运营决策平台",
+)
+NEW_NAME = "Ai-DOP智慧运营管理系统"
+TODAY = "2026-06-22"
+CHANGE_NOTE = "统一系统名称为 Ai-DOP智慧运营管理系统"
+
+
+def iter_all_paragraphs(doc: Document):
+    for p in doc.paragraphs:
+        yield p
+    for table in doc.tables:
+        for row in table.rows:
+            for cell in row.cells:
+                for p in cell.paragraphs:
+                    yield p
+
+
+def replace_in_paragraph(paragraph, old: str, new: str) -> bool:
+    text = paragraph.text
+    if old not in text:
+        return False
+    if paragraph.runs:
+        combined = "".join(r.text for r in paragraph.runs)
+        if old not in combined:
+            return False
+        new_text = combined.replace(old, new)
+        paragraph.runs[0].text = new_text
+        for run in paragraph.runs[1:]:
+            run.text = ""
+    else:
+        paragraph.add_run(text.replace(old, new))
+    return True
+
+
+def replace_system_name(doc: Document) -> int:
+    changed = 0
+    for old in OLD_NAMES:
+        for p in iter_all_paragraphs(doc):
+            if replace_in_paragraph(p, old, NEW_NAME):
+                changed += 1
+    return changed
+
+
+def parse_metadata(doc: Document) -> dict[str, str]:
+    meta = {"author": "智造易项目组", "created": "", "updated": "", "version": ""}
+    for p in doc.paragraphs[:25]:
+        t = p.text.strip()
+        if t.startswith("文档作者:"):
+            meta["author"] = t.split(":", 1)[1].strip()
+        elif t.startswith("创建日期:"):
+            meta["created"] = t.split(":", 1)[1].strip()
+        elif t.startswith("更新日期:"):
+            meta["updated"] = t.split(":", 1)[1].strip()
+        elif t.startswith("当前版本:"):
+            meta["version"] = t.split(":", 1)[1].strip()
+        elif t.startswith("版本:"):
+            m = re.search(r"版本:\s*(\S+)", t)
+            if m:
+                meta["version"] = m.group(1)
+            m = re.search(r"日期:\s*([^作者]+?)(?:\s+作者:|$)", t)
+            if m and not meta["updated"]:
+                meta["updated"] = m.group(1).strip()
+            m = re.search(r"作者:\s*(.+)$", t)
+            if m:
+                meta["author"] = m.group(1).strip()
+    if doc.tables:
+        for row in doc.tables[0].rows:
+            cells = [c.text.strip() for c in row.cells]
+            if len(cells) >= 2 and cells[0] == "版本" and cells[1].startswith("V"):
+                meta["version"] = meta["version"] or cells[1]
+            if len(cells) >= 2 and cells[0] == "编制单位":
+                meta["author"] = meta["author"] or cells[1]
+    if not meta["updated"]:
+        meta["updated"] = meta["created"] or TODAY
+    if not meta["created"]:
+        meta["created"] = meta["updated"]
+    if not meta["version"]:
+        meta["version"] = "V1.0"
+    return meta
+
+
+def find_toc_paragraph(doc: Document) -> Paragraph | None:
+    for p in doc.paragraphs:
+        if p.text.strip() == "目录":
+            return p
+    return None
+
+
+def has_system_name(doc: Document) -> bool:
+    for p in doc.paragraphs[:20]:
+        if NEW_NAME in p.text or any(old in p.text for old in OLD_NAMES):
+            return True
+    return False
+
+
+def insert_system_name_line(doc: Document) -> bool:
+    if has_system_name(doc):
+        return False
+    anchor = None
+    for p in doc.paragraphs[:20]:
+        if p.text.strip().startswith("文档作者:"):
+            anchor = p
+            break
+    if anchor is None:
+        return False
+    el = OxmlElement("w:p")
+    anchor._element.addprevious(el)
+    para = Paragraph(el, anchor._parent)
+    para.add_run(NEW_NAME)
+    return True
+
+
+def add_para_before(ref: Paragraph, text: str = "", *, style: str | None = None, page_break: bool = False) -> Paragraph:
+    el = OxmlElement("w:p")
+    ref._element.addprevious(el)
+    para = Paragraph(el, ref._parent)
+    if page_break:
+        para.add_run().add_break(WD_BREAK.PAGE)
+    if text:
+        run = para.add_run(text)
+        if style == "Heading 1":
+            run.bold = True
+    if style:
+        para.style = style
+    return para
+
+
+def fill_version_table(table, rows: list[tuple[str, str, str, str]]) -> None:
+    for ri, row_data in enumerate(rows):
+        for ci, val in enumerate(row_data):
+            cell = table.rows[ri].cells[ci]
+            cell.text = ""
+            run = cell.paragraphs[0].add_run(val)
+            run.font.size = Pt(10.5)
+            run.bold = ri == 0
+
+
+def version_rows(meta: dict[str, str]) -> list[tuple[str, str, str, str]]:
+    header = ("版本", "日期", "修订人", "修订说明")
+    current = (meta["version"], TODAY, meta["author"], CHANGE_NOTE)
+    initial_ver = "V0.1"
+    if meta["version"] in {"V1.0", "V2.0"}:
+        initial_ver = "V1.0" if meta["version"] != "V2.0" else "V1.0"
+    initial = (initial_ver, meta["created"], meta["author"], "初稿")
+    if current[0] == initial[0] and current[1] == initial[1]:
+        return [header, current]
+    return [header, current, initial]
+
+
+def find_version_record_table(doc: Document):
+    for i, p in enumerate(doc.paragraphs):
+        if p.text.strip() != "版本记录":
+            continue
+        body = doc.element.body
+        children = list(body)
+        idx = children.index(p._element)
+        for el in children[idx + 1 : idx + 4]:
+            if el.tag.endswith("tbl"):
+                for table in doc.tables:
+                    if table._tbl is el:
+                        return table
+    return None
+
+
+def upsert_version_record(doc: Document, meta: dict[str, str]) -> str:
+    rows = version_rows(meta)
+    existing = find_version_record_table(doc)
+    if existing is not None:
+        current = rows[1]
+        version, date, author, note = current
+        for table_row in existing.rows[1:]:
+            cells = [c.text.strip() for c in table_row.cells]
+            if cells and cells[0] == version and note in "".join(cells):
+                return "version-table-exists"
+        new_row = existing.add_row()
+        for ci, val in enumerate(current):
+            cell = new_row.cells[ci]
+            cell.text = ""
+            run = cell.paragraphs[0].add_run(val)
+            run.font.size = Pt(10.5)
+        return "version-table-updated"
+
+    toc = find_toc_paragraph(doc)
+    if toc is None:
+        return "no-toc"
+
+    add_para_before(toc, page_break=True)
+    add_para_before(toc, "版本记录", style="Heading 1")
+    table = doc.add_table(rows=len(rows), cols=4)
+    tbl_el = table._tbl
+    doc.element.body.remove(tbl_el)
+    toc._element.addprevious(tbl_el)
+    fill_version_table(table, rows)
+    return "version-table-added"
+
+
+def process_file(path: Path) -> dict[str, int | str]:
+    doc = Document(path)
+    meta = parse_metadata(doc)
+    name_changes = replace_system_name(doc)
+    inserted_name = insert_system_name_line(doc)
+    version_action = upsert_version_record(doc, meta)
+    doc.save(path)
+    return {
+        "name_changes": name_changes,
+        "inserted_name_line": int(inserted_name),
+        "version_action": version_action,
+    }
+
+
+def main() -> None:
+    for mod in ["S0", "S1", "S2", "S3", "S4"]:
+        mod_dir = BASE / mod
+        if not mod_dir.is_dir():
+            print(f"{mod}: missing directory")
+            continue
+        for path in sorted(mod_dir.glob("*.docx")):
+            result = process_file(path)
+            print(
+                f"{mod}/{path.name}: "
+                f"name={result['name_changes']} "
+                f"insert_line={result['inserted_name_line']} "
+                f"{result['version_action']}"
+            )
+
+
+if __name__ == "__main__":
+    main()

+ 698 - 0
doc/_export_quote_doc_compare_excel.py

@@ -0,0 +1,698 @@
+# -*- coding: utf-8 -*-
+"""Export S0-S2 quote vs requirement/blueprint comparison to Excel."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import openpyxl
+from docx import Document
+from openpyxl.styles import Alignment, Font, PatternFill
+from openpyxl.utils import get_column_letter
+
+QUOTE = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目报价"
+    r"\AI-DOP数字化运营平台开发报价清单_工时内容补充_增加算法_20260630.xlsx"
+)
+DELIVERY = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
+)
+OUT_XLSX = Path(__file__).resolve().parent / "plan" / "S0-S2-报价与交付文档对比表.xlsx"
+
+DOCS = {
+    "S0": (
+        DELIVERY / "S0" / "S0-基础主数据-业务需求描述-V0.2.docx",
+        DELIVERY / "S0" / "S0-基础主数据建模-业务蓝图设计方案-V0.2.docx",
+    ),
+    "S1": (
+        DELIVERY / "S1" / "S1产销协同业务需求描述.docx",
+        DELIVERY / "S1" / "S1产销协同模块蓝图设计方案.docx",
+    ),
+    "S2": (
+        DELIVERY / "S2" / "S2制造协同业务需求描述.docx",
+        DELIVERY / "S2" / "S2制造协同模块蓝图设计方案.docx",
+    ),
+}
+
+MOD_QUOTE_NAME = {"S0": "运营建模", "S1": "产销协同", "S2": "制造协同"}
+
+HEADERS = [
+    "模块",
+    "报价需求模块",
+    "报价需求项",
+    "需求项描述",
+    "需求文档",
+    "蓝图文档",
+    "说明",
+]
+
+
+def load_quote_corpus() -> dict:
+    """Load verbatim quote text from 汇总 / 明细 / 算法清单."""
+    wb = openpyxl.load_workbook(QUOTE, data_only=True)
+    corpus: dict = {"summary": {}, "detail": {}, "algo": {}, "kanban": {}}
+
+    ws = wb["报价汇总-.NET方案"]
+    for mod, name in MOD_QUOTE_NAME.items():
+        for r in range(1, ws.max_row + 1):
+            if ws.cell(r, 3).value == name:
+                corpus["summary"][mod] = {
+                    "front": str(ws.cell(r, 4).value or ""),
+                    "front_days": ws.cell(r, 5).value,
+                    "back": str(ws.cell(r + 1, 4).value or ""),
+                    "db": str(ws.cell(r + 2, 4).value or ""),
+                }
+                break
+
+    ws = wb["报价明细-.NET方案"]
+    blueprint_map = {
+        "S1": "产销协同蓝图设计",
+        "S2": "制造协同蓝图设计",
+    }
+    for mod, label in blueprint_map.items():
+        for r in range(1, ws.max_row + 1):
+            if ws.cell(r, 2).value == label:
+                corpus["detail"][f"{mod}_blueprint"] = (
+                    f"项目准备及业务蓝图设计 / {label}:"
+                    f"开发人数 {ws.cell(r, 4).value},"
+                    f"开发天数 {ws.cell(r, 5).value},"
+                    f"预估人天 {ws.cell(r, 6).value}"
+                )
+                break
+
+    detail_blocks = {
+        "S0": "运营建模",
+        "S1": "产销协同",
+        "S2": "制造协同",
+    }
+    for mod, block in detail_blocks.items():
+        rows = []
+        in_block = False
+        for r in range(1, ws.max_row + 1):
+            c2, c3 = ws.cell(r, 2).value, ws.cell(r, 3).value
+            if c2 == block:
+                in_block = True
+            if in_block and c2 and c2 != block and c2 in detail_blocks.values() and c2 != block:
+                break
+            if in_block and c3 and c3 != "单项小计":
+                rows.append(
+                    f"功能开发和业务实现 / {block} / {c3}:"
+                    f"开发人数 {ws.cell(r, 4).value},"
+                    f"开发天数 {ws.cell(r, 5).value},"
+                    f"预估人天 {ws.cell(r, 6).value}"
+                )
+        corpus["detail"][mod] = rows
+
+    for r in range(1, ws.max_row + 1):
+        if ws.cell(r, 2).value == "九宫格智慧运营看板":
+            mod_name = ws.cell(r, 3).value
+            for mod, qn in MOD_QUOTE_NAME.items():
+                if qn == mod_name:
+                    corpus["kanban"][mod] = (
+                        f"九宫格智慧运营看板 / {mod_name}:"
+                        f"开发人数 {ws.cell(r, 4).value},"
+                        f"开发天数 {ws.cell(r, 5).value},"
+                        f"预估人天 {ws.cell(r, 6).value}"
+                    )
+
+    ws = wb["算法清单"]
+    for r in range(3, ws.max_row + 1):
+        mod_name = ws.cell(r, 2).value
+        algo_text = str(ws.cell(r, 3).value or "")
+        for mod, qn in MOD_QUOTE_NAME.items():
+            if qn == mod_name:
+                corpus["algo"][mod] = algo_text
+
+    wb.close()
+    return corpus
+
+
+def load_doc_text(path: Path) -> str:
+    doc = Document(path)
+    parts = [p.text for p in doc.paragraphs]
+    for table in doc.tables:
+        for row in table.rows:
+            for cell in row.cells:
+                parts.append(cell.text)
+    return "\n".join(parts)
+
+
+def match_status(text: str, keywords: list[str], *, required: list[str] | None = None) -> str:
+    if not keywords:
+        return "—"
+    hits = [k for k in keywords if k in text]
+    if required and all(r in text for r in required):
+        return "✅"
+    if required and any(r in text for r in required):
+        return "⚠️"
+    if len(hits) == len(keywords):
+        return "✅"
+    if hits:
+        return "⚠️"
+    return "❌"
+
+
+def split_quote_segments(line: str, sep: str) -> list[str]:
+    if ":" in line:
+        line = line.split(":", 1)[1]
+    return [s.strip() for s in line.split(sep) if s.strip()]
+
+
+def find_segment(full: str, keyword: str, sep: str = "、") -> str:
+    for seg in split_quote_segments(full, sep):
+        if keyword in seg or seg.startswith(keyword):
+            return seg
+    return full
+
+
+def make_row(
+    module: str,
+    quote_module: str,
+    quote_item: str,
+    item_desc: str,
+    req_doc: str,
+    blue_doc: str,
+    note: str,
+) -> dict:
+    if (
+        "🔴" not in note
+        and req_doc not in ("—", "✅")
+        and (req_doc == "❌" or blue_doc == "❌")
+    ):
+        note = f"🔴 报价已列此项,交付文档未覆盖或未写全。{note}" if note else "🔴 报价已列此项,交付文档未覆盖或未写全。"
+    return {
+        "模块": module,
+        "报价需求模块": quote_module,
+        "报价需求项": quote_item,
+        "需求项描述": item_desc,
+        "需求文档": req_doc,
+        "蓝图文档": blue_doc,
+        "说明": note,
+    }
+
+
+def build_s0(req_t: str, blue_t: str, q: dict) -> list[dict]:
+    m = "S0"
+    s = q["summary"][m]
+    rows: list[dict] = []
+
+    front_items = [
+        (
+            "主数据维护(物料/客户/供应商/人员组织)",
+            "主数据维护页面",
+            ["物料", "客户", "供应商"],
+            ["员工", "组织", "人员"],
+            "需求文档按列表逐项描述物料/客户/供应商等;蓝图第2–6章按域覆盖。",
+        ),
+        (
+            "BOM与工艺路线维护",
+            "BOM与工艺路线维护",
+            ["BOM", "工艺路线"],
+            ["标准 BOM", "工艺"],
+            "需求含标准BOM、工艺路线、工序等;蓝图「制造主数据」章有对应方案。",
+        ),
+        (
+            "仓储库位与条码规则",
+            "仓储库位与条码规则",
+            ["库位", "条码", "批次"],
+            ["仓储", "货架"],
+            "需求含库位、批次、条码规则;蓝图「仓储主数据」章有对应内容。",
+        ),
+        (
+            "质量检验标准配置",
+            "质量检验标准配置",
+            ["检验标准", "检验项目", "抽样"],
+            ["IQC", "质量"],
+            "需求含检验项目/标准/抽样;蓝图「质量主数据」章有检验基准类描述。",
+        ),
+        (
+            "指标目录与业务口径配置",
+            "指标目录与业务口径配置",
+            ["指标"],
+            ["业务口径", "指标目录", "口径定义"],
+            "需求文档有指标/KPI相关条目的覆盖不完整;蓝图未单独设指标/口径章节。",
+        ),
+        (
+            "指标图谱浏览",
+            "指标图谱浏览",
+            ["指标"],
+            ["图谱", "指标图谱"],
+            "报价前端单独列出指标图谱;需求仅有指标类描述,蓝图无「图谱浏览」专章。",
+        ),
+    ]
+    for item, key, rkw, bkw, note in front_items:
+        desc = find_segment(s["front"], key)
+        if s["front_days"]:
+            desc = f"{desc}(模块前端合计 {s['front_days']} 人天;原文:{s['front']})"
+        rows.append(
+            make_row(
+                m,
+                "【汇总·前端】运营建模",
+                item,
+                desc,
+                match_status(req_t, rkw, required=rkw[:2] if len(rkw) >= 2 else rkw),
+                match_status(blue_t, bkw if bkw else rkw),
+                note,
+            )
+        )
+
+    for seg in split_quote_segments(s["back"], "、"):
+        rows.append(
+            make_row(
+                m,
+                "【汇总·后端】运营建模",
+                seg,
+                f"后端:{seg}(原文:{s['back']})",
+                match_status(req_t, [seg[:4]] if len(seg) >= 4 else [seg[:2]]),
+                match_status(blue_t, [seg[:4]] if len(seg) >= 4 else [seg[:2]]),
+                "后端能力在需求列表条目中有分散体现;蓝图以域方案描述为主,未逐条复现报价原文。",
+            )
+        )
+
+    for seg in split_quote_segments(s["db"], ","):
+        rows.append(
+            make_row(
+                m,
+                "【汇总·数据库】运营建模",
+                seg,
+                f"数据库:{seg}(原文:{s['db']})",
+                match_status(req_t, [w for w in ["物料", "BOM", "库位", "检验", "指标", "参数"] if w in seg]),
+                match_status(blue_t, [w for w in ["物料", "BOM", "库位", "检验", "指标", "参数"] if w in seg]),
+                "数据对象在需求条目中分散出现;蓝图未单独设「数据库设计」节,按域方案间接覆盖。",
+            )
+        )
+
+    detail_rows = q["detail"].get(m, [])
+    for i, (item, note) in enumerate(
+        [
+            ("数据建模", "报价明细工时包;对应需求文档大量列表型主数据条目。"),
+            ("业务建模", "报价明细工时包;对应蓝图 Word 按销售/供应/制造/质量/仓储五域组织。"),
+        ]
+    ):
+        desc = detail_rows[i] if i < len(detail_rows) else item
+        rows.append(
+            make_row(
+                m,
+                "【明细】功能开发",
+                item,
+                desc,
+                "—",
+                "✅",
+                note,
+            )
+        )
+
+    rows.append(
+        make_row(
+            m,
+            "【算法清单】",
+            "(本模块无单独算法行)",
+            "算法清单 sheet 自「产销协同」起列出模块算法;运营建模未单列算法条目。",
+            "—",
+            "—",
+            "不属于报有他无;本版报价未对 S0 承诺独立算法清单项。",
+        )
+    )
+    return rows
+
+
+def build_s1(req_t: str, blue_t: str, q: dict) -> list[dict]:
+    m = "S1"
+    s = q["summary"][m]
+    algo_full = q["algo"].get(m, "")
+    rows: list[dict] = []
+
+    front_specs = [
+        ("订单评审", True, "(一)订单评审管理 REQ-S1-003", "蓝图第2章 订单评审管理",
+         "报价前端与需求(一)、蓝图第2章一致,可逐项验收。"),
+        ("合同评审", False, None, None,
+         "报价汇总前端明确列出;需求文档无(一)之前章节,无 REQ-S1-002 专章;蓝图无 FUNC-S1-002 章。"),
+        ("产品设计", False, None, None,
+         "报价汇总前端明确列出;需求/蓝图均无 REQ-S1-001 / FUNC-S1-001 对应章节。"),
+        ("订单交付", True, "(二)订单交付管理 REQ-S1-004", "蓝图第3章",
+         "报价「订单交付」与需求(二)、蓝图第3章一致。"),
+        ("发货", True, "(三)订单发货管理 REQ-S1-005", "蓝图第4章",
+         "报价「发货」与需求(三)、蓝图第4章一致。"),
+        ("工单下达", True, "(四)工单下达 REQ-S1-006", "蓝图第5章",
+         "报价与需求(四)、蓝图第5章一致。"),
+        ("计划联动看板", True, "(五)计划联动看板 REQ-S1-007", "蓝图第6章",
+         "报价与需求(五)、蓝图第6章一致。"),
+        ("产销协同看板", True, "(七)产销协同看板 REQ-S1-009", "蓝图第8章",
+         "报价与需求(七)、蓝图第8章一致。"),
+    ]
+    for key, ok, req_ref, blue_ref, note in front_specs:
+        seg = find_segment(s["front"], key, "/")
+        desc = f"{seg}(模块前端合计 {s['front_days']} 人天;原文:{s['front']})"
+        rows.append(
+            make_row(
+                m,
+                "【汇总·前端】产销协同",
+                key,
+                desc,
+                "✅" if ok else "❌",
+                "✅" if ok else "❌",
+                note if not ok else f"{note} 对照位置:需求{req_ref};{blue_ref}。",
+            )
+        )
+
+    for seg in split_quote_segments(s["back"], "、"):
+        kw_map = {
+            "销售订单": ["订单", "变更"],
+            "合同": ["评审", "合同"],
+            "交期": ["交期", "齐套"],
+            "发货计划": ["发货", "ASN"],
+            "工单下达": ["工单下达"],
+            "订单交付进度": ["交付", "跟踪"],
+            "发货达成": ["发货", "异常"],
+            "看板指标": ["看板", "指标"],
+        }
+        kw = next((v for k, v in kw_map.items() if k in seg), [seg[:2]])
+        extra = ""
+        if "设计评审" in seg or "合同" in seg:
+            extra = "其中合同评审、设计评审在 Word 中缺独立章节。"
+        elif "产品设计与评审资料" in seg:
+            extra = ""
+        rows.append(
+            make_row(
+                m,
+                "【汇总·后端】产销协同",
+                seg,
+                f"后端:{seg}(原文:{s['back']})",
+                match_status(req_t, kw),
+                match_status(blue_t, kw),
+                f"报价后端能力项;需求/蓝图在相关功能章有分散描述。{extra}".strip(),
+            )
+        )
+
+    for seg in split_quote_segments(s["db"], ","):
+        kw = [w for w in ["订单", "合同", "产品设计", "发货", "工单", "交付", "缺料", "看板"] if w in seg]
+        note = ""
+        if "产品设计" in seg:
+            note = "报价数据库对象含产品设计资料;需求/蓝图均无产品设计专章,无法直接对照。"
+        rows.append(
+            make_row(
+                m,
+                "【汇总·数据库】产销协同",
+                seg,
+                f"数据库:{seg}(原文:{s['db']})",
+                match_status(req_t, kw),
+                match_status(blue_t, kw),
+                note or "报价数据域在需求/蓝图相关章节中间接体现。",
+            )
+        )
+
+    detail_specs = [
+        ("产销协同蓝图设计", q["detail"].get("S1_blueprint", ""), "—", "✅",
+         "报价明细 11 人天蓝图设计;交付件为《S1产销协同模块蓝图设计方案》Word 全文。"),
+        ("订单管理", None, "⚠️", "⚠️",
+         "报价 2 人天打包项,覆盖订单评审/合同/产品设计/交付/发货等;Word 缺 001/002 章,003–005 有。"),
+        ("工单管理", None, "✅", "✅",
+         "报价 2 人天;与需求(四)工单下达、蓝图第5章一致。"),
+        ("计划管理", None, "⚠️", "⚠️",
+         "报价 2 人天;对应计划联动看板+需求明细核验;后者在报价汇总前端未单独点名。"),
+        ("产销协同看板", None, "✅", "✅",
+         "报价 1 人天;与需求(七)、蓝图第8章一致。"),
+    ]
+    detail_lines = q["detail"].get(m, [])
+    for i, (key, _, rs, bs, note) in enumerate(detail_specs):
+        desc = detail_lines[i] if i < len(detail_lines) else key
+        if key == "产销协同蓝图设计":
+            desc = q["detail"].get("S1_blueprint", desc)
+        rows.append(
+            make_row(m, "【明细】", key, desc, rs, bs, note)
+        )
+
+    if q["kanban"].get(m):
+        rows.append(
+            make_row(
+                m,
+                "【明细】九宫格智慧运营看板",
+                "产销协同",
+                q["kanban"][m],
+                "❌",
+                "❌",
+                "报价另列 2 人天九宫格产销看板;需求/蓝图仅模块内产销协同看板,未说明与九宫格条目关系。",
+            )
+        )
+
+    for algo in split_quote_segments(algo_full, "、"):
+        kw_map = {
+            "订单资源检查": (["交期", "齐套", "库存"], "003 订单评审"),
+            "供需缺口": (["缺料", "差额", "满足"], "008 需求明细核验"),
+            "安全库存": ([], None),
+            "订单交期模拟": (["交期", "预计交付"], "003 订单评审"),
+        }
+        kw, ref = [], None
+        for k, (w, r) in kw_map.items():
+            if k in algo:
+                kw, ref = w, r
+                break
+        rs = match_status(req_t, kw) if kw else "❌"
+        bs = match_status(blue_t, kw) if kw else "❌"
+        ref_note = f"业务上最接近 {ref}。" if ref else "需求/蓝图全文无相关表述。"
+        rows.append(
+            make_row(
+                m,
+                "【算法清单】产销协同",
+                algo,
+                f"{algo}(模块算法原文:{algo_full})",
+                rs,
+                bs,
+                f"报价算法清单列名;Word 未出现该算法名称,{ref_note} 验收时需补算法说明或改报价。",
+            )
+        )
+
+    rows.append(
+        make_row(
+            m,
+            "(他有报无·供参考)",
+            "需求明细核验",
+            "未出现在报价汇总前端 8 项中;明细「计划管理」可能覆盖。",
+            "✅",
+            "✅",
+            "需求(六)/蓝图第7章有专章;报价汇总前端未单独列出,属文档有而报价汇总未点名。",
+        )
+    )
+    return rows
+
+
+def build_s2(req_t: str, blue_t: str, q: dict) -> list[dict]:
+    m = "S2"
+    s = q["summary"][m]
+    algo_full = q["algo"].get(m, "")
+    rows: list[dict] = []
+
+    front_specs = [
+        ("生产排程", ["生产排程", "REQ-S2-001"], ["生产排程", "FUNC-S2-001"],
+         "报价与需求(一)、蓝图第2章一致。"),
+        ("可执行日计划", ["日计划", "REQ-S2-003"], ["作业计划", "FUNC-S2-003"],
+         "报价「可执行日计划」对应需求(二)作业计划、蓝图第3章。"),
+        ("产线日历与休息加班", ["产线日历", "休息", "加班", "节假日"],
+         ["REQ-S2-004", "REQ-S2-005"],
+         "报价合并表述;需求有日历/休息/节假日/加班多条(004–007);蓝图合并在作业计划章,未逐条分章。"),
+        ("工单进度看板", ["工单", "进度", "REQ-S2-008"], ["进度"],
+         "报价「工单进度看板」;需求 REQ-S2-008 有;蓝图无独立章标题,合并在看板章。"),
+        ("制造协同看板", ["制造协同看板", "REQ-S2-009"], ["FUNC-S2-009"],
+         "报价与需求(三)看板、蓝图第4章一致。"),
+    ]
+    for key, rkw, bkw, note in front_specs:
+        seg = find_segment(s["front"], key.split("与")[0], "/")
+        if key not in seg and key == "产线日历与休息加班":
+            seg = "产线日历与休息加班"
+        desc = f"{seg}(模块前端合计 {s['front_days']} 人天;原文:{s['front']})"
+        rows.append(
+            make_row(
+                m,
+                "【汇总·前端】制造协同",
+                key,
+                desc,
+                match_status(req_t, rkw, required=rkw[:1]),
+                match_status(blue_t, bkw, required=bkw[:1]),
+                note,
+            )
+        )
+
+    for seg in split_quote_segments(s["back"], "、"):
+        note = ""
+        if "重排" in seg or "冲突" in seg:
+            note = "需求 REQ-S2-002 排产异常有覆盖;「冲突识别/动态重排」表述与报价原文不完全一致。"
+        rows.append(
+            make_row(
+                m,
+                "【汇总·后端】制造协同",
+                seg,
+                f"后端:{seg}(原文:{s['back']})",
+                match_status(req_t, [w for w in ["排程", "日计划", "日历", "异常", "进度", "看板"] if w in seg][:2] or [seg[:2]]),
+                match_status(blue_t, [w for w in ["排程", "日计划", "日历", "异常", "进度", "看板"] if w in seg][:2] or [seg[:2]]),
+                note or "报价后端项;需求/蓝图在排程、日计划、看板章有分散描述。",
+            )
+        )
+
+    for seg in split_quote_segments(s["db"], ","):
+        rows.append(
+            make_row(
+                m,
+                "【汇总·数据库】制造协同",
+                seg,
+                f"数据库:{seg}(原文:{s['db']})",
+                match_status(req_t, [w for w in ["排程", "日计划", "日历", "进度", "异常", "工艺", "看板"] if w in seg]),
+                match_status(blue_t, [w for w in ["排程", "日计划", "日历", "进度", "异常", "工艺", "看板"] if w in seg]),
+                "报价数据域;需求/蓝图未单独设库表清单,在方案描述中间接体现。",
+            )
+        )
+
+    detail_specs = [
+        ("制造协同蓝图设计", "—", "✅",
+         "报价明细 11 人天;交付件为《S2制造协同模块蓝图设计方案》Word。"),
+        ("制造需求管理", "❌", "❌",
+         "报价明细 2 人天独立行;需求/蓝图均无「制造需求管理」专章或同名能力描述。"),
+        ("生产排程", "✅", "✅",
+         "报价 2 人天;与需求(一)、蓝图第2章一致。"),
+        ("作业计划", "✅", "✅",
+         "报价 2 人天;与需求(二)及日历/休息/节假日/加班条目、蓝图第3章一致。"),
+        ("制造协同看板", "✅", "✅",
+         "报价 1 人天;与需求(三)、蓝图第4章一致。"),
+    ]
+    detail_lines = q["detail"].get(m, [])
+    for i, (key, rs, bs, note) in enumerate(detail_specs):
+        if key == "制造协同蓝图设计":
+            desc = q["detail"].get("S2_blueprint", detail_lines[0] if detail_lines else key)
+        else:
+            idx = i - 1 if i > 0 else 0
+            desc = detail_lines[idx] if idx < len(detail_lines) else key
+        rows.append(make_row(m, "【明细】", key, desc, rs, bs, note))
+
+    if q["kanban"].get(m):
+        rows.append(
+            make_row(
+                m,
+                "【明细】九宫格智慧运营看板",
+                "制造协同",
+                q["kanban"][m],
+                "❌",
+                "❌",
+                "报价另列 2 人天;需求/蓝图仅有模块制造协同看板,未说明与九宫格条目对应关系。",
+            )
+        )
+
+    for algo in split_quote_segments(algo_full, "、"):
+        kw_map = {
+            "齐套校验": (["齐套"], "排程/工单相关描述"),
+            "多维约束排程": (["排程", "约束", "产能"], "生产排程章"),
+            "扰动重排": (["排产异常", "异常"], "REQ-S2-002"),
+        }
+        kw, ref = [], ""
+        for k, (w, r) in kw_map.items():
+            if k in algo:
+                kw, ref = w, r
+                break
+        rows.append(
+            make_row(
+                m,
+                "【算法清单】制造协同",
+                algo,
+                f"{algo}(模块算法原文:{algo_full})",
+                match_status(req_t, kw),
+                match_status(blue_t, kw),
+                f"报价算法清单列名;Word 未写算法名称,相关业务最接近 {ref or '未找到'}。",
+            )
+        )
+
+    rows.append(
+        make_row(
+            m,
+            "(文档扩展项)",
+            "排产异常记录 REQ-S2-002",
+            "未在报价汇总/算法清单中单列条目;需求文档有 REQ-S2-002 专条。",
+            "✅",
+            "⚠️",
+            "需求有排产异常记录;蓝图无独立 FUNC-S2-002 章标题,内容可能在排程章中。",
+        )
+    )
+    return rows
+
+
+def style_sheet(ws) -> None:
+    header_fill = PatternFill("solid", fgColor="1F3864")
+    header_font = Font(color="FFFFFF", bold=True)
+    for col, h in enumerate(HEADERS, 1):
+        cell = ws.cell(1, col, h)
+        cell.fill = header_fill
+        cell.font = header_font
+        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+    ws.freeze_panes = "A2"
+    widths = [6, 20, 28, 52, 10, 10, 56]
+    for i, w in enumerate(widths, 1):
+        ws.column_dimensions[get_column_letter(i)].width = w
+    for row_cells in ws.iter_rows(min_row=2, max_row=ws.max_row):
+        for cell in row_cells:
+            cell.alignment = Alignment(vertical="top", wrap_text=True)
+        req_v = str(row_cells[4].value or "")
+        blue_v = str(row_cells[5].value or "")
+        note_v = str(row_cells[6].value or "")
+        if "🔴" in note_v or ((req_v == "❌" or blue_v == "❌") and req_v != "—"):
+            for cell in row_cells:
+                cell.fill = PatternFill("solid", fgColor="FCE4D6")
+
+
+def write_excel(all_rows: list[dict]) -> None:
+    OUT_XLSX.parent.mkdir(parents=True, exist_ok=True)
+    wb = openpyxl.Workbook()
+    wb.remove(wb.active)
+
+    by_mod: dict[str, list[dict]] = {"S0": [], "S1": [], "S2": []}
+    for r in all_rows:
+        by_mod[r["模块"]].append(r)
+
+    for mod in ["S0", "S1", "S2"]:
+        ws = wb.create_sheet(mod)
+        for c, h in enumerate(HEADERS, 1):
+            ws.cell(1, c, h)
+        for ri, data in enumerate(by_mod[mod], 2):
+            for c, h in enumerate(HEADERS, 1):
+                ws.cell(ri, c, data[h])
+        style_sheet(ws)
+
+    ws_all = wb.create_sheet("全部", 0)
+    for c, h in enumerate(HEADERS, 1):
+        ws_all.cell(1, c, h)
+    for ri, data in enumerate(all_rows, 2):
+        for c, h in enumerate(HEADERS, 1):
+            ws_all.cell(ri, c, data[h])
+    style_sheet(ws_all)
+
+    info = wb.create_sheet("说明")
+    info["A1"] = "对比说明"
+    lines = [
+        f"报价单:{QUOTE.name}",
+        "生成日期:2026-06-22",
+        "列「需求项描述」:摘自报价单原文(汇总/明细/算法清单),含工时或完整句式。",
+        "列「需求文档」「蓝图文档」:✅ 有对应章节或内容  ⚠️ 部分有  ❌ 无  — 不适用",
+        "浅橙行:报价已列但交付文档未覆盖(说明列含 🔴)或双 ❌",
+        "S0:…业务需求描述-V0.2.docx / …业务蓝图设计方案-V0.2.docx",
+        "S1/S2:各模块业务需求描述.docx + 模块蓝图设计方案.docx",
+    ]
+    for i, line in enumerate(lines, 2):
+        info.cell(i, 1, line)
+
+    wb.save(OUT_XLSX)
+
+
+def main() -> None:
+    corpus = load_quote_corpus()
+    all_rows: list[dict] = []
+    for mod, (req_p, blue_p) in DOCS.items():
+        req_t = load_doc_text(req_p)
+        blue_t = load_doc_text(blue_p)
+        if mod == "S0":
+            all_rows.extend(build_s0(req_t, blue_t, corpus))
+        elif mod == "S1":
+            all_rows.extend(build_s1(req_t, blue_t, corpus))
+        else:
+            all_rows.extend(build_s2(req_t, blue_t, corpus))
+
+    write_excel(all_rows)
+    print(f"Wrote {len(all_rows)} rows -> {OUT_XLSX}")
+
+
+if __name__ == "__main__":
+    main()

+ 142 - 0
doc/_extend_delivery_front_matter.py

@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+"""Apply S0 blueprint front matter to all S0-S4 delivery docx (recursive)."""
+from __future__ import annotations
+
+import importlib.util
+import shutil
+import tempfile
+from pathlib import Path
+
+from docx import Document
+from docx.oxml.ns import qn
+from docx.text.paragraph import Paragraph
+
+BASE = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
+)
+MODULES = ["S0", "S1", "S2", "S3", "S4"]
+
+
+def load_module(name: str, filename: str):
+    path = Path(__file__).with_name(filename)
+    spec = importlib.util.spec_from_file_location(name, path)
+    mod = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(mod)
+    return mod
+
+
+def iter_delivery_docx() -> list[Path]:
+    files: list[Path] = []
+    for module in MODULES:
+        mod_dir = BASE / module
+        if not mod_dir.is_dir():
+            continue
+        for path in sorted(mod_dir.rglob("*.docx")):
+            if path.name.startswith("~$"):
+                continue
+            files.append(path)
+    return files
+
+
+def has_toc(doc: Document) -> bool:
+    return any(p.text.strip() == "目录" for p in doc.paragraphs)
+
+
+def has_page_break(p: Paragraph) -> bool:
+    return bool(p._element.findall(".//" + qn("w:br")))
+
+
+def front_matter_status(doc: Document) -> str:
+    if not has_toc(doc):
+        return "skip-no-toc"
+
+    has_control = any(p.text.strip() == "文档控制" for p in doc.paragraphs)
+    has_change = any(p.text.strip() == "更改记录" for p in doc.paragraphs)
+    has_version = any(p.text.strip() == "版本记录" for p in doc.paragraphs)
+    has_old_meta = any(
+        p.text.strip().startswith(prefix)
+        for p in doc.paragraphs[:20]
+        for prefix in ("文档作者:", "创建日期:", "当前版本:")
+    )
+    has_cover_table = any(
+        table.rows
+        and table.rows[0].cells[0].text.strip() == "文档编号"
+        and len(table.rows) == 5
+        for table in doc.tables
+    )
+
+    version_break = False
+    toc_break = False
+    for p in doc.paragraphs:
+        t = p.text.strip()
+        if t == "版本记录":
+            version_break = has_page_break(p)
+        if t == "目录":
+            toc_break = has_page_break(p)
+
+    if has_control or has_change or has_old_meta or not has_cover_table:
+        return "rebuild"
+    if not has_version:
+        return "rebuild"
+    if not version_break or not toc_break:
+        return "adjust"
+    return "ok"
+
+
+def infer_module_kind(path: Path) -> tuple[str, str]:
+    rel = path.relative_to(BASE)
+    module = rel.parts[0]
+    kind = "blue" if "蓝图" in path.name else "req"
+    return module, kind
+
+
+def save_with_fallback(doc: Document, path: Path) -> None:
+    try:
+        doc.save(path)
+    except PermissionError:
+        tmp = Path(tempfile.gettempdir()) / f"docfix_{path.name}"
+        doc.save(tmp)
+        try:
+            shutil.copy2(tmp, path)
+            tmp.unlink(missing_ok=True)
+        except PermissionError:
+            print(f"  !! {path.name} 被占用,已写入: {tmp}")
+
+
+def process_file(path: Path) -> None:
+    rel = path.relative_to(BASE)
+    doc = Document(path)
+    status = front_matter_status(doc)
+    if status == "skip-no-toc":
+        print(f"{rel}: skip (无目录)")
+        return
+    if status == "ok":
+        print(f"{rel}: ok")
+        return
+
+    module, kind = infer_module_kind(path)
+    adjust = load_module("adjust", "_adjust_front_matter.py")
+
+    if status == "rebuild":
+        unify = load_module("unify", "_unify_doc_format.py")
+        restore = load_module("restore", "_restore_cover_format.py")
+        versions = load_module("versions", "_unify_version_records.py")
+
+        unify.unify_document(path, module, kind)
+        restore.restore_file(path)
+        versions.process_file(path)
+        adjust.adjust_file(path)
+        print(f"{rel}: rebuilt + formatted")
+        return
+
+    adjust.adjust_file(path)
+    print(f"{rel}: adjusted page breaks")
+
+
+def main() -> None:
+    for path in iter_delivery_docx():
+        process_file(path)
+
+
+if __name__ == "__main__":
+    main()

+ 417 - 0
doc/_fix_delivery_docs.py

@@ -0,0 +1,417 @@
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml import OxmlElement
+from docx.shared import Inches, Pt
+from docx.text.paragraph import Paragraph
+from PIL import Image, ImageDraw, ImageFont
+
+BASE = Path(r"D:\Projects\Ai-DOP\项目\项目管理\产互联项目管理\交付文档")
+MARKER = "【原型页面】"
+W, H = 1200, 680
+
+REMOVE_SECTION_STARTS = (
+    "补充:原型设计",
+    "补充:系统数据流",
+    "六、核心业务流程",
+    "七、业务数据流",
+    "补充:核心业务流程",
+    "补充:业务数据流",
+)
+HEADING_RE = re.compile(r"^(FUNC-S(\d+)-(\d+(?:~\d+)?))(?:\s+(.*))?$")
+
+DOMAIN_MENU = {
+    "manufacturing": "制造建模",
+    "sales": "产销建模",
+    "supply": "供应建模",
+    "warehouse": "仓储建模",
+    "quality": "质量建模",
+    "kanban": "运营指标建模",
+}
+
+
+def load_fonts():
+    try:
+        return (
+            ImageFont.truetype("msyh.ttc", 11),
+            ImageFont.truetype("msyh.ttc", 12),
+            ImageFont.truetype("msyh.ttc", 14),
+            ImageFont.truetype("msyh.ttc", 18),
+        )
+    except OSError:
+        d = ImageFont.load_default()
+        return d, d, d, d
+
+
+def delete_paragraph(paragraph: Paragraph):
+    el = paragraph._element
+    el.getparent().remove(el)
+
+
+def paragraph_has_image(paragraph: Paragraph) -> bool:
+    xml = paragraph._element.xml
+    return "w:drawing" in xml or "w:pict" in xml or "pic:pic" in xml
+
+
+def is_func_heading(text: str) -> bool:
+    text = text.strip()
+    if re.fullmatch(r"FUNC-S\d+-\d{3}", text):
+        return False
+    return bool(HEADING_RE.match(text))
+
+
+def func_code(text: str) -> str | None:
+    m = HEADING_RE.match(text.strip())
+    return m.group(1) if m else None
+
+
+def clean_document(path: Path) -> int:
+    doc = Document(path)
+    removed = 0
+    i = 0
+    while i < len(doc.paragraphs):
+        p = doc.paragraphs[i]
+        text = p.text.strip()
+
+        if any(text.startswith(s) for s in REMOVE_SECTION_STARTS):
+            delete_paragraph(p)
+            removed += 1
+            while i < len(doc.paragraphs):
+                t = doc.paragraphs[i].text.strip()
+                if not t or re.match(r"^\d+\.", t):
+                    delete_paragraph(doc.paragraphs[i])
+                    removed += 1
+                    continue
+                break
+            continue
+
+        if text == MARKER:
+            delete_paragraph(p)
+            removed += 1
+            if i < len(doc.paragraphs) and paragraph_has_image(doc.paragraphs[i]):
+                delete_paragraph(doc.paragraphs[i])
+                removed += 1
+            if i < len(doc.paragraphs):
+                t = doc.paragraphs[i].text.strip()
+                if t.startswith("图:"):
+                    delete_paragraph(doc.paragraphs[i])
+                    removed += 1
+            continue
+
+        if text.startswith("图:") and "页面原型" in text:
+            delete_paragraph(p)
+            removed += 1
+            continue
+
+        i += 1
+
+    doc.save(path)
+    return removed
+
+
+def insert_after(paragraph: Paragraph, text: str = "") -> Paragraph:
+    new_p = OxmlElement("w:p")
+    paragraph._p.addnext(new_p)
+    new_para = Paragraph(new_p, paragraph._parent)
+    if text:
+        new_para.add_run(text)
+    return new_para
+
+
+def rr(draw, xy, radius=6, **kw):
+    draw.rounded_rectangle(xy, radius=radius, **kw)
+
+
+def draw_text(draw, xy, value, font, fill="#444444"):
+    draw.text(xy, value, fill=fill, font=font)
+
+
+def infer_domain(page_path: str) -> str:
+    page_path = page_path.lower()
+    if "quality" in page_path:
+        return "quality"
+    if "kanban" in page_path:
+        return "kanban"
+    for key in ("manufacturing", "sales", "supply", "warehouse"):
+        if key in page_path:
+            return key
+    return "manufacturing"
+
+
+def infer_headers(desc: str) -> list[str]:
+    patterns = [
+        r"列表字段包括([^。]+)",
+        r"字段包括([^。]+)",
+        r"字段含([^。,]+)",
+        r"列表字段为:([^。]+)",
+        r"指标字段:([^。]+)",
+    ]
+    for pat in patterns:
+        m = re.search(pat, desc)
+        if not m:
+            continue
+        parts = re.split(r"[、,,/]", re.sub(r"\([^)]*\)", "", m.group(1)))
+        headers = [p.strip() for p in parts if p.strip() and len(p.strip()) <= 12][:6]
+        if headers:
+            if "操作" not in headers:
+                headers.append("操作")
+            return headers
+    if "路线" in desc and "工序" in desc:
+        return ["路线编码", "名称", "适用物料", "版本", "状态", "操作"]
+    return ["编码", "名称", "状态", "更新时间", "操作"]
+
+
+def infer_filters(desc: str) -> list[str]:
+    m = re.search(r"支持按([^筛选]+)筛选", desc)
+    if m:
+        out = [p.strip() for p in re.split(r"[、/]", m.group(1)) if p.strip()][:4]
+        if out:
+            return out
+    if "产线" in desc and "员工" in desc:
+        return ["产线", "员工"]
+    return ["关键字", "公司", "工厂"]
+
+
+def infer_add_label(title: str) -> str:
+    return "新增" + title.replace("管理", "") if "管理" in title else "新增"
+
+
+def infer_form_fields(title: str) -> list[str]:
+    if "工单控制参数" in title:
+        return ["默认批次规则", "下达前置时间", "其他控制参数"]
+    return ["参数项", "参数值", "说明"]
+
+
+def render_crud_png(path, *, module, menu, title, subtitle, filters, headers, add_label, row):
+    xs, s, m, b = load_fonts()
+    img = Image.new("RGB", (W, H), "#fafafa")
+    draw = ImageDraw.Draw(img)
+    draw.rectangle([0, 0, W, 44], fill="#ffffff", outline="#d9d9d9")
+    draw_text(draw, (190, 14), f"Ai-DOP / {module}", s, "#444444")
+    draw.rectangle([0, 44, 170, H], fill="#1f2937")
+    draw_text(draw, (16, 72), module, s, "#ffffff")
+    draw_text(draw, (32, 98), menu, xs, "#cbd5e1")
+    draw.rectangle([170, 44, W, H], fill="#f3f4f6")
+    rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
+    draw_text(draw, (210, 72), title[:18], b, "#ffffff")
+    draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
+    x = 210
+    for label in filters[:4]:
+        draw_text(draw, (x, 132), label[:8], xs, "#444444")
+        rr(draw, [x, 148, x + 110, 174], radius=4, outline="#d9d9d9", fill="#ffffff")
+        x += 130
+    bx = 210
+    for label, color in [("查询", "#1677ff"), ("重置", "#ffffff"), (add_label[:8], "#52c41a")]:
+        w = max(64, len(label) * 14 + 24)
+        rr(draw, [bx, 188, bx + w, 216], radius=4, fill=color, outline=color if color != "#ffffff" else "#d9d9d9")
+        draw_text(draw, (bx + 10, 194), label, s, "#ffffff" if color != "#ffffff" else "#444444")
+        bx += w + 10
+    rr(draw, [190, 232, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
+    draw.rectangle([190, 232, W - 20, 266], fill="#fafafa", outline="#d9d9d9")
+    col_w = max(80, (W - 230) // max(len(headers), 1))
+    for i, h in enumerate(headers):
+        draw_text(draw, (200 + i * col_w, 242), h[:8], xs, "#333333")
+    if row:
+        draw.line([190, 276, W - 20, 276], fill="#f0f0f0", width=1)
+        for i, cell in enumerate(row[: len(headers)]):
+            draw_text(draw, (200 + i * col_w, 286), str(cell)[:10], xs, "#666666")
+    path.parent.mkdir(parents=True, exist_ok=True)
+    img.save(path, format="PNG")
+
+
+def render_form_png(path, *, module, menu, title, subtitle, fields):
+    xs, s, m, b = load_fonts()
+    img = Image.new("RGB", (W, H), "#fafafa")
+    draw = ImageDraw.Draw(img)
+    draw.rectangle([170, 44, W, H], fill="#f3f4f6")
+    rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
+    draw_text(draw, (210, 72), title, b, "#ffffff")
+    draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
+    rr(draw, [190, 132, W - 20, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
+    y = 150
+    for label in fields:
+        draw_text(draw, (210, y), label, xs, "#444444")
+        rr(draw, [210, y + 16, 520, y + 42], radius=4, outline="#d9d9d9", fill="#ffffff")
+        y += 56
+    rr(draw, [210, H - 58, 274, H - 30], radius=4, fill="#1677ff")
+    draw_text(draw, (226, H - 52), "保存", s, "#ffffff")
+    path.parent.mkdir(parents=True, exist_ok=True)
+    img.save(path, format="PNG")
+
+
+def render_kpi_graph_png(path, title, subtitle):
+    xs, s, m, b = load_fonts()
+    img = Image.new("RGB", (W, H), "#fafafa")
+    draw = ImageDraw.Draw(img)
+    rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
+    draw_text(draw, (210, 72), title, b, "#ffffff")
+    draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
+    rr(draw, [190, 132, W - 340, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
+    draw_text(draw, (210, 148), "L1/L2 指标关系图谱", s, "#333333")
+    rr(draw, [W - 310, 132, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
+    draw_text(draw, (W - 290, 148), "节点编辑抽屉", s, "#333333")
+    img.save(path, format="PNG")
+
+
+def render_kpi_module_png(path, title, subtitle):
+    xs, s, m, b = load_fonts()
+    img = Image.new("RGB", (W, H), "#fafafa")
+    draw = ImageDraw.Draw(img)
+    rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
+    draw_text(draw, (210, 72), title, b, "#ffffff")
+    draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
+    rr(draw, [190, 132, 520, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
+    draw_text(draw, (210, 148), "L1/L2 指标勾选", s, "#333333")
+    rr(draw, [540, 132, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
+    draw_text(draw, (560, 148), "布局预览", s, "#333333")
+    img.save(path, format="PNG")
+
+
+def parse_s0_funcs(doc: Document) -> dict[str, dict]:
+    funcs: dict[str, dict] = {}
+    current = None
+    desc_parts: list[str] = []
+    for p in doc.paragraphs:
+        text = p.text.strip()
+        if is_func_heading(text):
+            if current:
+                funcs[current]["desc"] = "".join(desc_parts)
+            m = HEADING_RE.match(text)
+            current = m.group(1)
+            funcs[current] = {
+                "title": (m.group(4) or "").strip() or current,
+                "desc": "",
+                "req": "",
+                "page": "",
+            }
+            desc_parts = []
+            continue
+        if not current:
+            continue
+        if text.startswith("**对应需求**:"):
+            funcs[current]["req"] = text.split(":", 1)[1].strip()
+        elif text.startswith("**前端页面**:") or text.startswith("**前端实现**:"):
+            funcs[current]["page"] = text.split(":", 1)[1].strip(" `")
+        elif text.startswith("**功能描述**:"):
+            continue
+        elif text.startswith("FUNC-") or text.startswith("补充:") or text.startswith("【") or text.startswith("图:"):
+            continue
+        elif text.startswith("**") or text in {"功能编号", "功能名称", "页面文件"}:
+            continue
+        elif text:
+            desc_parts.append(text)
+    if current:
+        funcs[current]["desc"] = "".join(desc_parts)
+    return funcs
+
+
+def build_s0_png(code: str, info: dict) -> Path:
+    title = info["title"]
+    desc = info["desc"]
+    req = info.get("req") or code.replace("FUNC", "REQ")
+    page = info.get("page", "")
+    domain = infer_domain(page)
+    menu = DOMAIN_MENU.get(domain, "数据建模")
+    subtitle = f"{req} · {page}" if page else req
+    out = BASE / "S0" / "prototype" / f"{code}.png"
+
+    if "表单形式" in desc or code == "FUNC-S0-018":
+        render_form_png(out, module="S0 运营建模", menu=menu, title=title, subtitle=subtitle, fields=infer_form_fields(title))
+        return out
+    if code in ("FUNC-S0-055", "FUNC-S0-056"):
+        render_kpi_graph_png(out, title, subtitle)
+        return out
+    if code == "FUNC-S0-058":
+        render_crud_png(
+            out,
+            module="S0 运营建模",
+            menu=menu,
+            title=title,
+            subtitle=subtitle,
+            filters=["模块", "层级", "关键字"],
+            headers=["指标编码", "显示名称", "模块", "层级", "启用", "操作"],
+            add_label="新增指标",
+            row=["S1_L1_001", "订单评审周期", "S1", "L1", "是", "编辑"],
+        )
+        return out
+    if code in ("FUNC-S0-057", "FUNC-S0-059"):
+        render_kpi_module_png(out, title, subtitle)
+        return out
+
+    headers = infer_headers(desc)
+    filters = infer_filters(desc)
+    row = []
+    for h in headers:
+        if h == "操作":
+            row.append("编辑")
+        elif "编码" in h or h.endswith("号"):
+            row.append("001")
+        elif "名称" in h or "描述" in h:
+            row.append("示例")
+        elif "状态" in h or "启用" in h or "确认" in h:
+            row.append("是")
+        else:
+            row.append("-")
+    render_crud_png(
+        out,
+        module="S0 运营建模",
+        menu=menu,
+        title=title,
+        subtitle=subtitle,
+        filters=filters,
+        headers=headers,
+        add_label=infer_add_label(title),
+        row=row,
+    )
+    return out
+
+
+def insert_prototypes(doc_path: Path, mapping: dict[str, Path]):
+    doc = Document(doc_path)
+    headings = [(i, func_code(p.text)) for i, p in enumerate(doc.paragraphs) if is_func_heading(p.text)]
+    inserts = []
+    for idx, (start, code) in enumerate(headings):
+        if not code or code not in mapping:
+            continue
+        end = headings[idx + 1][0] if idx + 1 < len(headings) else len(doc.paragraphs)
+        inserts.append((end - 1, code, mapping[code]))
+    for insert_at, code, png in reversed(inserts):
+        anchor = doc.paragraphs[insert_at]
+        cap = insert_after(anchor, MARKER)
+        cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        pic_p = insert_after(cap)
+        pic_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        pic_p.add_run().add_picture(str(png), width=Inches(6.2))
+        note = insert_after(pic_p, f"图:{code}")
+        note.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        for r in note.runs:
+            r.font.size = Pt(9)
+    doc.save(doc_path)
+
+
+def main():
+    for mod in ["S0", "S1", "S2"]:
+        for kind in ["功能设计说明", "需求说明书"]:
+            n = clean_document(BASE / mod / f"{mod}-{kind}.docx")
+            print(f"cleaned {mod}-{kind}: {n}")
+
+    s0_doc = Document(BASE / "S0" / "S0-功能设计说明.docx")
+    funcs = parse_s0_funcs(s0_doc)
+    mapping = {code: build_s0_png(code, info) for code, info in funcs.items()}
+    insert_prototypes(BASE / "S0" / "S0-功能设计说明.docx", mapping)
+    print(f"S0 prototypes: {len(mapping)}")
+
+    from _insert_func_prototypes import build_templates, process_doc
+
+    templates = build_templates(BASE / "_prototype_templates")
+    for mod in ["S1", "S2"]:
+        process_doc(mod, templates)
+
+
+if __name__ == "__main__":
+    main()

+ 361 - 0
doc/_insert_func_prototypes.py

@@ -0,0 +1,361 @@
+# -*- coding: utf-8 -*-
+"""Generate wireframe PNGs and insert into S0-S2 功能设计说明.docx."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Callable
+
+from docx import Document
+from docx.shared import Inches, Pt
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml import OxmlElement
+from docx.text.paragraph import Paragraph
+from PIL import Image, ImageDraw, ImageFont
+
+BASE = Path(r"D:\Projects\Ai-DOP\项目\项目管理\产互联项目管理\交付文档")
+MARKER = "【原型页面】"
+
+W, H = 1200, 680
+
+
+def load_fonts():
+    try:
+        s = ImageFont.truetype("msyh.ttc", 12)
+        m = ImageFont.truetype("msyh.ttc", 14)
+        b = ImageFont.truetype("msyh.ttc", 18)
+        xs = ImageFont.truetype("msyh.ttc", 11)
+    except OSError:
+        s = m = b = xs = ImageFont.load_default()
+    return xs, s, m, b
+
+
+def rr(draw, xy, radius=6, **kw):
+    draw.rounded_rectangle(xy, radius=radius, **kw)
+
+
+def text(draw, xy, value, font, fill="#444444"):
+    draw.text(xy, value, fill=fill, font=font)
+
+
+def shell(draw, xs, s, b, module, menu, title, subtitle):
+    draw.rectangle([0, 0, W, 44], fill="#ffffff", outline="#d9d9d9")
+    text(draw, (190, 14), f"Ai-DOP / {module}", s, "#444444")
+    draw.rectangle([0, 44, 170, H], fill="#1f2937")
+    text(draw, (16, 72), module, s, "#ffffff")
+    text(draw, (32, 98), menu, xs, "#cbd5e1")
+    draw.rectangle([170, 44, W, H], fill="#f3f4f6")
+    rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
+    text(draw, (210, 72), title, b, "#ffffff")
+    text(draw, (210, 98), subtitle, xs, "#cbd5e1")
+
+
+def filters(draw, xs, s, y, labels):
+    x = 210
+    for label in labels:
+        text(draw, (x, y), label, xs, "#444444")
+        rr(draw, [x, y + 16, x + 110, y + 42], radius=4, outline="#d9d9d9", fill="#ffffff")
+        text(draw, (x + 8, y + 24), "全部", xs, "#888888")
+        x += 130
+
+
+def toolbar(draw, s, y, buttons):
+    x = 210
+    for label, color in buttons:
+        w = max(64, len(label) * 14 + 24)
+        rr(draw, [x, y, x + w, y + 28], radius=4, fill=color, outline=color if color != "#ffffff" else "#d9d9d9")
+        fill = "#ffffff" if color != "#ffffff" else "#444444"
+        text(draw, (x + 10, y + 6), label, s, fill)
+        x += w + 10
+
+
+def table(draw, xs, s, y, headers, row=None, height=300):
+    rr(draw, [190, y, W - 20, y + height], radius=8, fill="#ffffff", outline="#d9d9d9")
+    draw.rectangle([190, y, W - 20, y + 34], fill="#fafafa", outline="#d9d9d9")
+    cols = len(headers)
+    col_w = (W - 230) // cols
+    for i, h in enumerate(headers):
+        text(draw, (200 + i * col_w, y + 10), h, xs, "#333333")
+    if row:
+        draw.line([190, y + 74, W - 20, y + 74], fill="#f0f0f0", width=1)
+        for i, cell in enumerate(row):
+            text(draw, (200 + i * col_w, y + 52), cell, xs, "#666666")
+
+
+def save_tpl(path: Path, draw_fn: Callable):
+    img = Image.new("RGB", (W, H), "#fafafa")
+    draw = ImageDraw.Draw(img)
+    draw_fn(draw)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    img.save(path, format="PNG")
+
+
+def tpl_crud(draw, module, menu, title, subtitle, filters_labels, add_label, headers, row):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, module, menu, title, subtitle)
+    filters(draw, xs, s, 132, filters_labels)
+    toolbar(draw, s, 188, [("查询", "#1677ff"), ("重置", "#ffffff"), (add_label, "#52c41a")])
+    table(draw, xs, s, 232, headers, row)
+
+
+def tpl_form(draw, module, menu, title, subtitle, fields):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, module, menu, title, subtitle)
+    rr(draw, [190, 132, W - 20, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
+    y = 150
+    for label in fields:
+        text(draw, (210, y), label, xs, "#444444")
+        rr(draw, [210, y + 16, 520, y + 42], radius=4, outline="#d9d9d9", fill="#ffffff")
+        y += 56
+    toolbar(draw, s, H - 58, [("保存", "#1677ff"), ("重置", "#ffffff")])
+
+
+def tpl_kpi_graph(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "智慧运营看板", "运营指标建模", "运营指标建模", "FUNC-S0-055/056 指标图谱与关系编辑")
+    rr(draw, [190, 132, W - 340, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
+    text(draw, (210, 148), "L1/L2 指标关系图谱", s, "#333333")
+    for i in range(5):
+        x = 240 + i * 130
+        y = 220 + (i % 2) * 80
+        rr(draw, [x, y, x + 90, y + 34], radius=6, fill="#e6f4ff", outline="#91caff")
+        text(draw, (x + 10, y + 10), f"L1-{i+1}", xs, "#1677ff")
+    rr(draw, [W - 310, 132, W - 20, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
+    text(draw, (W - 290, 148), "右侧编辑抽屉", s, "#333333")
+    text(draw, (W - 290, 178), "显示名称", xs, "#444444")
+    rr(draw, [W - 290, 194, W - 40, 220], radius=4, outline="#d9d9d9")
+    text(draw, (W - 290, 236), "计算说明 / 聚合方式", xs, "#444444")
+    rr(draw, [W - 290, 252, W - 40, 310], radius=4, outline="#d9d9d9")
+
+
+def tpl_kpi_module(draw, title):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "智慧运营看板", "详情看板配置", title, "勾选 L1/L2 指标并保存布局")
+    rr(draw, [190, 132, 520, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
+    text(draw, (210, 148), "模块指标勾选区", s, "#333333")
+    for i, name in enumerate(["订单评审周期", "订单满足率", "交付及时率", "异常闭环率"]):
+        text(draw, (220, 182 + i * 34), f"☑ {name}", xs, "#444444")
+    rr(draw, [540, 132, W - 20, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
+    text(draw, (560, 148), "预览区 / 样式选择", s, "#333333")
+    toolbar(draw, s, H - 58, [("保存布局", "#1677ff")])
+
+
+def tpl_kpi_master(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "智慧运营看板", "指标目录", "指标目录管理", "FUNC-S0-058 KpiMaster")
+    filters(draw, xs, s, 132, ["模块", "层级", "关键字"])
+    toolbar(draw, s, 188, [("查询", "#1677ff"), ("新增指标", "#52c41a")])
+    table(draw, xs, s, 232, ["指标编码", "显示名称", "模块", "层级", "父级", "启用", "操作"], ["S1_L1_001", "订单评审周期", "S1", "L1", "-", "是", "编辑"])
+
+
+def tpl_scheduling(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "S2 制造协同", "生产排程", "工单工序排程", "FUNC-S2-001 生产排程")
+    filters(draw, xs, s, 132, ["工单号", "物料", "开工日期", "状态"])
+    toolbar(draw, s, 188, [("工单关闭", "#ff4d4f"), ("生产排程", "#1677ff"), ("同步物料需求", "#1677ff"), ("同步工艺路线", "#ffffff")])
+    table(draw, xs, s, 232, ["工单号", "物料", "数量", "优先级", "齐套", "状态", "操作"], ["WO001", "MAT-A", "100", "普通", "齐套", "初始", "加急/追踪"])
+
+
+def tpl_daily_plan(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "S2 制造协同", "作业计划", "可执行日计划", "FUNC-S2-003 可执行日计划")
+    filters(draw, xs, s, 132, ["生产指令", "计划日期", "工作中心", "下达状态"])
+    toolbar(draw, s, 188, [("查询", "#1677ff"), ("下达", "#52c41a")])
+    table(draw, xs, s, 232, ["状态", "生产指令", "工序", "设备", "计划数量", "计划日期", "操作"], ["待下达", "WO001", "10", "设备A", "100", "2026-06-22", "勾选"])
+
+
+def tpl_progress(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "S2 制造协同", "制造协同看板", "工单执行进度看板", "FUNC-S2-008 工单执行进度看板")
+    filters(draw, xs, s, 132, ["工单号", "物料", "计划开工"])
+    table(draw, xs, s, 188, ["工单号", "物料", "工单数量", "入库数量", "领料状态", "工单进度"], ["WO001", "MAT-A", "100", "60", "已领料", "60%"], height=360)
+
+
+def tpl_module_kanban(draw, module, title):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "智慧运营看板", module, title, "L1 指标卡 + L2 趋势图")
+    y = 132
+    x = 190
+    for i, name in enumerate(["指标A", "指标B", "指标C", "指标D"]):
+        rr(draw, [x, y, x + 230, y + 92], radius=8, fill="#ffffff", outline="#d9d9d9")
+        text(draw, (x + 12, y + 12), name, s, "#333333")
+        text(draw, (x + 12, y + 40), "当前值 / 目标值", xs, "#666666")
+        text(draw, (x + 12, y + 62), "状态:绿/黄/红", xs, "#888888")
+        x += 245
+    rr(draw, [190, 240, W - 20, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
+    text(draw, (210, 258), "L2 指标趋势图", s, "#333333")
+
+
+def tpl_order_review(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "S1 产销协同", "订单评审管理", "订单评审管理", "FUNC-S1-003 订单评审管理")
+    filters(draw, xs, s, 132, ["订单号", "客户", "日期范围"])
+    toolbar(draw, s, 188, [("订单评审", "#1677ff"), ("交期确认", "#fa8c16"), ("3级计划重排", "#ffffff"), ("添加", "#52c41a")])
+    table(draw, xs, s, 232, ["订单号", "客户", "状态", "审批进度", "建议交期", "操作"], ["SO001", "客户A", "评审", "审批中", "2026-07-01", "编辑/变更"])
+
+
+def tpl_delivery(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "S1 产销协同", "订单交付管理", "订单交付管理", "FUNC-S1-004 订单交付管理")
+    filters(draw, xs, s, 132, ["订单号", "客户", "交付状态"])
+    table(draw, xs, s, 188, ["订单号", "客户", "交付状态", "目标交期", "实际进度", "操作"], ["SO001", "客户A", "生产中", "2026-07-01", "65%", "销售订单/交付跟踪"], height=360)
+
+
+def tpl_dispatch(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "S1 产销协同", "工单下达", "工单下达", "FUNC-S1-006 工单下达")
+    filters(draw, xs, s, 132, ["工单号", "物料", "开工日期", "状态"])
+    toolbar(draw, s, 188, [("齐套检查", "#1677ff"), ("生成物料需求", "#1677ff")])
+    table(draw, xs, s, 232, ["工单号", "物料", "数量", "齐套状态", "状态", "操作"], ["WO001", "MAT-A", "100", "齐套", "待下达", "工单下达"])
+
+
+def tpl_product_design(draw):
+    xs, s, m, b = load_fonts()
+    shell(draw, xs, s, b, "S1 产销协同", "产品设计管理", "产品设计管理", "FUNC-S1-001 产品设计管理")
+    filters(draw, xs, s, 132, ["单据编号", "合同号", "产品类型"])
+    toolbar(draw, s, 188, [("新建产品设计", "#1677ff")])
+    table(draw, xs, s, 232, ["单据编号", "合同号", "产品类型", "设计负责人", "图号", "操作"], ["PD202606220001", "CR001", "常规", "张三", "DW-001", "查看/编辑"])
+
+
+def build_templates(out_dir: Path) -> dict[str, Path]:
+    paths: dict[str, Path] = {}
+
+    def put(key: str, filename: str, fn):
+        p = out_dir / filename
+        save_tpl(p, fn)
+        paths[key] = p
+
+    put("s0_crud", "tpl-s0-crud.png", lambda d: tpl_crud(d, "S0 运营建模", "主数据维护", "主数据列表", "S0 标准 CRUD 页面", ["关键字", "公司", "工厂", "启用"], "新增", ["编码", "名称", "状态", "更新时间", "操作"], ["001", "示例名称", "启用", "2026-06-22", "编辑"]))
+    put("s0_form", "tpl-s0-form.png", lambda d: tpl_form(d, "S0 运营建模", "制造建模", "工单控制参数", "FUNC-S0-018 工单控制参数管理", ["默认批次规则", "下达前置时间", "其他控制参数"]))
+    put("s0_quality", "tpl-s0-quality.png", lambda d: tpl_crud(d, "S0 运营建模", "质量建模", "检验项目管理", "FUNC-S0-042~054 质量域主数据", ["关键字", "公司", "工厂"], "新增", ["编码", "名称", "状态", "操作"], ["IQC-001", "外观检验", "启用", "编辑"]))
+    put("s0_kpi_graph", "tpl-s0-kpi-graph.png", tpl_kpi_graph)
+    put("s0_kpi_module", "tpl-s0-kpi-module.png", lambda d: tpl_kpi_module(d, "模块 L1/L2 指标配置"))
+    put("s0_kpi_master", "tpl-s0-kpi-master.png", tpl_kpi_master)
+    put("s1_design", "tpl-s1-design.png", tpl_product_design)
+    put("s1_crud", "tpl-s1-crud.png", lambda d: tpl_crud(d, "S1 产销协同", "合同评审管理", "合同评审管理", "FUNC-S1-002 合同评审管理", ["合同号", "客户", "日期"], "新建合同评审", ["合同号", "客户", "评审状态", "创建时间", "操作"], ["CR001", "客户A", "审批中", "2026-06-22", "编辑"]))
+    put("s1_order", "tpl-s1-order.png", tpl_order_review)
+    put("s1_delivery", "tpl-s1-delivery.png", tpl_delivery)
+    put("s1_ship", "tpl-s1-ship.png", lambda d: tpl_crud(d, "S1 产销协同", "订单发货管理", "订单发货管理", "FUNC-S1-005 订单发货管理", ["发货单号", "销售单号", "客户"], "新建发货单", ["发货单号", "销售单号", "客户", "发货日期", "操作"], ["SH001", "SO001", "客户A", "2026-06-22", "编辑"]))
+    put("s1_dispatch", "tpl-s1-dispatch.png", tpl_dispatch)
+    put("s1_linkage", "tpl-s1-linkage.png", lambda d: tpl_module_kanban(d, "计划联动看板", "计划联动看板"))
+    put("s1_verify", "tpl-s1-verify.png", lambda d: tpl_crud(d, "S1 产销协同", "需求明细核验", "需求明细核验", "FUNC-S1-008 需求明细核验", ["订单号", "物料", "状态"], "核验", ["订单号", "物料", "需求数量", "核验结果", "操作"], ["SO001", "MAT-A", "100", "通过", "查看"]))
+    put("s1_kanban", "tpl-s1-kanban.png", lambda d: tpl_module_kanban(d, "S1 产销协同看板", "S1 产销协同看板"))
+    put("s2_scheduling", "tpl-s2-scheduling.png", tpl_scheduling)
+    put("s2_exception", "tpl-s2-exception.png", lambda d: tpl_crud(d, "S2 制造协同", "排产异常记录", "排产异常记录", "FUNC-S2-002 排产异常记录", ["工单号", "执行时间", "物料"], "新增", ["工单号", "操作类型", "执行时间", "物料", "操作"], ["WO001", "手工调整", "2026-06-22", "MAT-A", "编辑"]))
+    put("s2_daily", "tpl-s2-daily.png", tpl_daily_plan)
+    put("s2_capacity", "tpl-s2-capacity.png", lambda d: tpl_crud(d, "S2 制造协同", "作业计划", "产线工作日历管理", "FUNC-S2-004~007 产线产能基础数据", ["产线", "类型"], "新增", ["产线", "日期/时段", "参数", "备注", "操作"], ["产线A", "周一", "8h", "-", "编辑"]))
+    put("s2_progress", "tpl-s2-progress.png", tpl_progress)
+    put("s2_kanban", "tpl-s2-kanban.png", lambda d: tpl_module_kanban(d, "S2 制造协同看板", "S2 制造协同看板"))
+    return paths
+
+
+FUNC_TEMPLATE = {}
+
+for n in range(1, 18):
+    FUNC_TEMPLATE[f"FUNC-S0-{n:03d}"] = "s0_crud"
+FUNC_TEMPLATE["FUNC-S0-018"] = "s0_form"
+for n in range(19, 20):
+    FUNC_TEMPLATE[f"FUNC-S0-{n:03d}"] = "s0_crud"
+for n in range(20, 42):
+    FUNC_TEMPLATE[f"FUNC-S0-{n:03d}"] = "s0_crud"
+FUNC_TEMPLATE["FUNC-S0-042~054"] = "s0_quality"
+FUNC_TEMPLATE["FUNC-S0-055"] = "s0_kpi_graph"
+FUNC_TEMPLATE["FUNC-S0-056"] = "s0_kpi_graph"
+FUNC_TEMPLATE["FUNC-S0-057"] = "s0_kpi_module"
+FUNC_TEMPLATE["FUNC-S0-058"] = "s0_kpi_master"
+FUNC_TEMPLATE["FUNC-S0-059"] = "s0_kpi_module"
+
+FUNC_TEMPLATE["FUNC-S1-001"] = "s1_design"
+FUNC_TEMPLATE["FUNC-S1-002"] = "s1_crud"
+FUNC_TEMPLATE["FUNC-S1-003"] = "s1_order"
+FUNC_TEMPLATE["FUNC-S1-004"] = "s1_delivery"
+FUNC_TEMPLATE["FUNC-S1-005"] = "s1_ship"
+FUNC_TEMPLATE["FUNC-S1-006"] = "s1_dispatch"
+FUNC_TEMPLATE["FUNC-S1-007"] = "s1_linkage"
+FUNC_TEMPLATE["FUNC-S1-008"] = "s1_verify"
+FUNC_TEMPLATE["FUNC-S1-009"] = "s1_kanban"
+
+FUNC_TEMPLATE["FUNC-S2-001"] = "s2_scheduling"
+FUNC_TEMPLATE["FUNC-S2-002"] = "s2_exception"
+FUNC_TEMPLATE["FUNC-S2-003"] = "s2_daily"
+for n in range(4, 8):
+    FUNC_TEMPLATE[f"FUNC-S2-{n:03d}"] = "s2_capacity"
+FUNC_TEMPLATE["FUNC-S2-008"] = "s2_progress"
+FUNC_TEMPLATE["FUNC-S2-009"] = "s2_kanban"
+
+HEADING_RE = re.compile(r"^(FUNC-S(\d+)-(\d+(?:~\d+)?))(?:\s+(.*))?$")
+
+
+def is_func_heading(text: str) -> bool:
+    text = text.strip()
+    if re.fullmatch(r"FUNC-S\d+-\d{3}", text):
+        return False
+    return bool(HEADING_RE.match(text))
+
+
+def func_code(text: str) -> str | None:
+    m = HEADING_RE.match(text.strip())
+    return m.group(1) if m else None
+
+
+def insert_after(paragraph: Paragraph, text: str = "") -> Paragraph:
+    new_p = OxmlElement("w:p")
+    paragraph._p.addnext(new_p)
+    new_para = Paragraph(new_p, paragraph._parent)
+    if text:
+        new_para.add_run(text)
+    return new_para
+
+
+def section_has_marker(doc: Document, start: int, end: int) -> bool:
+    for i in range(start, end):
+        if MARKER in doc.paragraphs[i].text:
+            return True
+    return False
+
+
+def process_doc(mod: str, templates: dict[str, Path]):
+    path = BASE / mod / f"{mod}-功能设计说明.docx"
+    doc = Document(path)
+    headings = [(i, func_code(p.text)) for i, p in enumerate(doc.paragraphs) if is_func_heading(p.text)]
+    inserts: list[tuple[int, str, Path]] = []
+
+    for idx, (start, code) in enumerate(headings):
+        if not code:
+            continue
+        end = headings[idx + 1][0] if idx + 1 < len(headings) else len(doc.paragraphs)
+        if section_has_marker(doc, start, end):
+            continue
+        tpl_key = FUNC_TEMPLATE.get(code)
+        if not tpl_key:
+            continue
+        png = templates[tpl_key]
+        insert_at = end - 1
+        inserts.append((insert_at, code, png))
+
+    for insert_at, code, png in reversed(inserts):
+        anchor = doc.paragraphs[insert_at]
+        cap = insert_after(anchor, MARKER)
+        cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        pic_p = insert_after(cap)
+        pic_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        run = pic_p.add_run()
+        run.add_picture(str(png), width=Inches(6.2))
+        note = insert_after(pic_p, f"图:{code}")
+        note.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        for r in note.runs:
+            r.font.size = Pt(9)
+
+    doc.save(path)
+    print(f"{mod}: inserted {len(inserts)} prototype images -> {path}")
+
+
+def main():
+    for mod in ["S0", "S1", "S2"]:
+        out_dir = BASE / mod / "prototype"
+        templates = build_templates(out_dir)
+        process_doc(mod, templates)
+
+
+if __name__ == "__main__":
+    main()

+ 110 - 0
doc/_recover_from_transcript.py

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""Replay Write/StrReplace tool calls from agent transcript onto repo."""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+TRANSCRIPT = Path(
+    r"C:\Users\skygu\.cursor\projects\d-Projects-Ai-DOP-SourceCode-ZZYDOP"
+    r"\agent-transcripts\09f6a7e3-4c30-4108-b1d7-f3d16f6412f6"
+    r"\09f6a7e3-4c30-4108-b1d7-f3d16f6412f6.jsonl"
+)
+
+PREFIXES = ("Web", "server", "doc")
+
+
+def to_rel(path: str) -> str | None:
+    p = path.replace("/", "\\")
+    m = re.search(r"ZZYDOP[\\/](.+)$", p, re.I)
+    if not m:
+        return None
+    rel = m.group(1)
+    if rel.startswith(PREFIXES):
+        return rel
+    return None
+
+
+def iter_ops():
+    text = TRANSCRIPT.read_text(encoding="utf-8", errors="replace")
+    for line in text.splitlines():
+        try:
+            obj = json.loads(line)
+        except json.JSONDecodeError:
+            continue
+        msg = obj.get("message", {})
+        content = msg.get("content", [])
+        if not isinstance(content, list):
+            continue
+        for part in content:
+            if not isinstance(part, dict) or part.get("type") != "tool_use":
+                continue
+            name = part.get("name")
+            inp = part.get("input", {})
+            if not isinstance(inp, dict):
+                continue
+            rel = to_rel(inp.get("path", ""))
+            if not rel:
+                continue
+            if name == "Write":
+                yield ("write", rel, inp.get("contents", ""))
+            elif name == "StrReplace":
+                yield (
+                    "replace",
+                    rel,
+                    inp.get("old_string", ""),
+                    inp.get("new_string", ""),
+                    bool(inp.get("replace_all")),
+                )
+
+
+def main() -> int:
+    writes = 0
+    replaces_ok = 0
+    replaces_fail: list[str] = []
+
+    for op in iter_ops():
+        kind = op[0]
+        rel = op[1]
+        target = REPO / rel
+        if kind == "write":
+            contents = op[2]
+            target.parent.mkdir(parents=True, exist_ok=True)
+            target.write_text(contents, encoding="utf-8", newline="\n")
+            writes += 1
+            print(f"WRITE {rel}")
+            continue
+
+        old, new, replace_all = op[2], op[3], op[4]
+        if not target.is_file():
+            replaces_fail.append(f"{rel}: file missing")
+            continue
+        try:
+            text = target.read_text(encoding="utf-8")
+        except UnicodeDecodeError:
+            replaces_fail.append(f"{rel}: not utf-8 text")
+            continue
+        if old not in text:
+            replaces_fail.append(f"{rel}: old_string not found")
+            continue
+        if replace_all:
+            text = text.replace(old, new)
+        else:
+            text = text.replace(old, new, 1)
+        target.write_text(text, encoding="utf-8", newline="\n")
+        replaces_ok += 1
+        print(f"REPLACE {rel}")
+
+    print(f"\nDone: writes={writes}, replaces_ok={replaces_ok}, replaces_fail={len(replaces_fail)}")
+    for item in replaces_fail[:40]:
+        print(f"  FAIL {item}")
+    if len(replaces_fail) > 40:
+        print(f"  ... and {len(replaces_fail) - 40} more")
+    return 0 if not replaces_fail else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 211 - 0
doc/_restore_cover_format.py

@@ -0,0 +1,211 @@
+# -*- coding: utf-8 -*-
+"""Restore delivery docx cover / document-control formatting (S0 blueprint style)."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from docx.shared import Pt, RGBColor
+from docx.table import Table
+from docx.text.paragraph import Paragraph
+
+BASE = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
+)
+FONT = "微软雅黑"
+BLUE = RGBColor(0x1F, 0x4E, 0x79)
+HEADER_FILL = "1F3864"
+TABLE_STYLE = "aff1"  # Table Grid in this template
+
+
+def body_blocks(doc: Document):
+    for child in doc.element.body:
+        if child.tag.endswith("p"):
+            yield "p", Paragraph(child, doc)
+        elif child.tag.endswith("tbl"):
+            yield "t", Table(child, doc)
+
+
+def find_toc_element(doc: Document):
+    for kind, obj in body_blocks(doc):
+        if kind == "p" and obj.text.strip() == "目录":
+            return obj._element
+    return None
+
+
+def set_run_font(run, *, size: Pt | None = None, bold: bool | None = None, color: RGBColor | None = None):
+    run.font.name = FONT
+    r_pr = run._element.get_or_add_rPr()
+    r_fonts = r_pr.rFonts
+    if r_fonts is None:
+        r_fonts = OxmlElement("w:rFonts")
+        r_pr.insert(0, r_fonts)
+    r_fonts.set(qn("w:ascii"), FONT)
+    r_fonts.set(qn("w:hAnsi"), FONT)
+    r_fonts.set(qn("w:eastAsia"), FONT)
+    if size is not None:
+        run.font.size = size
+    if bold is not None:
+        run.bold = bold
+    if color is not None:
+        run.font.color.rgb = color
+
+
+def format_title_paragraph(p: Paragraph, text: str, *, size: Pt, bold: bool = True, color: RGBColor = BLUE):
+    p.text = text
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    for run in p.runs:
+        set_run_font(run, size=size, bold=bold, color=color)
+
+
+def format_system_paragraph(p: Paragraph, text: str):
+    p.text = text
+    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    for run in p.runs:
+        set_run_font(run, size=Pt(13), bold=False, color=RGBColor(0, 0, 0))
+
+
+def apply_table_style(table: Table) -> None:
+    tbl = table._tbl
+    tbl_pr = tbl.tblPr
+    if tbl_pr is None:
+        tbl_pr = OxmlElement("w:tblPr")
+        tbl.insert(0, tbl_pr)
+    style = tbl_pr.find(qn("w:tblStyle"))
+    if style is None:
+        style = OxmlElement("w:tblStyle")
+        tbl_pr.insert(0, style)
+    style.set(qn("w:val"), TABLE_STYLE)
+    jc = tbl_pr.find(qn("w:jc"))
+    if jc is None:
+        jc = OxmlElement("w:jc")
+        jc.set(qn("w:val"), "center")
+        tbl_pr.append(jc)
+    else:
+        jc.set(qn("w:val"), "center")
+
+
+def shade_cell(cell, fill: str) -> None:
+    tc_pr = cell._element.get_or_add_tcPr()
+    old = tc_pr.find(qn("w:shd"))
+    if old is not None:
+        tc_pr.remove(old)
+    shd = OxmlElement("w:shd")
+    shd.set(qn("w:val"), "clear")
+    shd.set(qn("w:color"), "auto")
+    shd.set(qn("w:fill"), fill)
+    tc_pr.append(shd)
+
+
+def set_cell(
+    cell,
+    text: str,
+    *,
+    bold: bool = False,
+    white: bool = False,
+    size: Pt = Pt(10.5),
+    center: bool = False,
+):
+    cell.text = ""
+    p = cell.paragraphs[0]
+    if center:
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+    run = p.add_run(text)
+    set_run_font(run, size=size, bold=bold, color=RGBColor(255, 255, 255) if white else RGBColor(0, 0, 0))
+
+
+def format_cover_table(table: Table) -> None:
+    apply_table_style(table)
+    for row in table.rows:
+        set_cell(row.cells[0], row.cells[0].text.strip(), bold=True)
+        set_cell(row.cells[1], row.cells[1].text.strip())
+
+
+def format_change_table(table: Table) -> None:
+    apply_table_style(table)
+    header = table.rows[0]
+    for cell in header.cells:
+        shade_cell(cell, HEADER_FILL)
+        set_cell(cell, cell.text.strip(), bold=True, white=True, center=True)
+    for row in table.rows[1:]:
+        for cell in row.cells:
+            set_cell(cell, cell.text.strip(), center=True)
+
+
+def ensure_leading_blank(doc: Document, toc_el) -> None:
+    body = doc.element.body
+    children = list(body)
+    toc_pos = children.index(toc_el)
+    if toc_pos == 0:
+        blank = OxmlElement("w:p")
+        body.insert(0, blank)
+        return
+    first = children[0]
+    if first.tag.endswith("p"):
+        p = Paragraph(first, doc)
+        if p.text.strip():
+            blank = OxmlElement("w:p")
+            body.insert(0, blank)
+
+
+def insert_page_break_before_doc_control(doc: Document, toc_el) -> None:
+    from docx.enum.text import WD_BREAK
+
+    for kind, obj in body_blocks(doc):
+        if kind == "p" and obj.text.strip() == "文档控制":
+            if obj.runs:
+                obj.runs[0].add_break(WD_BREAK.PAGE)
+            else:
+                obj.add_run().add_break(WD_BREAK.PAGE)
+            return
+
+
+def restore_file(path: Path) -> None:
+    doc = Document(path)
+    toc_el = find_toc_element(doc)
+    if toc_el is None:
+        raise RuntimeError(f"未找到目录: {path.name}")
+
+    front_paras: list[Paragraph] = []
+    front_tables: list[Table] = []
+    for kind, obj in body_blocks(doc):
+        if obj._element is toc_el:
+            break
+        if kind == "p":
+            t = obj.text.strip()
+            if t:
+                front_paras.append(obj)
+        elif kind == "t":
+            front_tables.append(obj)
+
+    if len(front_paras) >= 3:
+        format_title_paragraph(front_paras[0], front_paras[0].text.strip(), size=Pt(28))
+        format_title_paragraph(front_paras[1], front_paras[1].text.strip(), size=Pt(22))
+        format_system_paragraph(front_paras[2], front_paras[2].text.strip())
+
+    for table in front_tables:
+        if not table.rows:
+            continue
+        cols = len(table.columns)
+        if cols == 2 and len(table.rows) == 5:
+            format_cover_table(table)
+        elif cols == 4 and len(table.rows) == 3:
+            format_change_table(table)
+
+    ensure_leading_blank(doc, toc_el)
+    insert_page_break_before_doc_control(doc, toc_el)
+    doc.save(path)
+
+
+def main() -> None:
+    for module in ["S0", "S1", "S2", "S3", "S4"]:
+        for path in sorted((BASE / module).glob("*.docx")):
+            restore_file(path)
+            print(f"restored {module}/{path.name}")
+
+
+if __name__ == "__main__":
+    main()

+ 287 - 0
doc/_unify_doc_format.py

@@ -0,0 +1,287 @@
+# -*- coding: utf-8 -*-
+"""Unify S0-S4 delivery docx front matter to S0 blueprint style.
+
+WARNING: Do not run blindly — rebuilds front matter and strips original formatting.
+Prefer surgical edits via _restore_cover_format.py after manual structure changes.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from docx import Document
+from docx.oxml import OxmlElement
+from docx.shared import Pt
+from docx.table import Table
+from docx.text.paragraph import Paragraph
+
+BASE = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
+)
+SYSTEM_NAME = "Ai-DOP智慧运营管理系统"
+V01, V02 = "V0.1", "V0.2"
+NOTE_V01, NOTE_V02 = "初版建立", "增加版本号"
+
+DOC_META = {
+    ("S0", "req"): {
+        "title1": "S0 基础主数据",
+        "title2": "业务需求描述",
+        "doc_no": "S0-BRD-001",
+        "author": "智造易项目组",
+        "created": "2026-06-10",
+        "updated": "2026-06-26",
+    },
+    ("S0", "blue"): {
+        "title1": "S0 基础主数据建模",
+        "title2": "业务蓝图设计方案",
+        "doc_no": "S0-BBP-001",
+        "author": "智造易项目组",
+        "created": "2026-06-10",
+        "updated": "2026-06-26",
+    },
+    ("S1", "req"): {
+        "title1": "S1 产销协同模块",
+        "title2": "业务需求描述",
+        "doc_no": "S1-BRD-001",
+        "author": "彭熙玉",
+        "created": "2026-06-09",
+        "updated": "2026-06-09",
+    },
+    ("S1", "blue"): {
+        "title1": "S1 产销协同模块",
+        "title2": "业务蓝图设计方案",
+        "doc_no": "S1-BBP-001",
+        "author": "彭熙玉",
+        "created": "2026-06-09",
+        "updated": "2026-06-09",
+    },
+    ("S2", "req"): {
+        "title1": "S2 制造协同模块",
+        "title2": "业务需求描述",
+        "doc_no": "S2-BRD-001",
+        "author": "彭熙玉",
+        "created": "2026-06-09",
+        "updated": "2026-06-09",
+    },
+    ("S2", "blue"): {
+        "title1": "S2 制造协同模块",
+        "title2": "业务蓝图设计方案",
+        "doc_no": "S2-BBP-001",
+        "author": "彭熙玉",
+        "created": "2026-06-09",
+        "updated": "2026-06-09",
+    },
+    ("S3", "req"): {
+        "title1": "S3 供应协同模块",
+        "title2": "业务需求描述",
+        "doc_no": "S3-BRD-001",
+        "author": "彭熙玉",
+        "created": "2026-06-10",
+        "updated": "2026-06-11",
+    },
+    ("S3", "blue"): {
+        "title1": "S3 供应协同模块",
+        "title2": "业务蓝图设计方案",
+        "doc_no": "S3-BBP-001",
+        "author": "彭熙玉",
+        "created": "2026-06-10",
+        "updated": "2026-06-10",
+    },
+    ("S4", "req"): {
+        "title1": "S4 采购执行模块",
+        "title2": "业务需求描述",
+        "doc_no": "S4-BRD-001",
+        "author": "彭熙玉",
+        "created": "2026-06-11",
+        "updated": "2026-06-11",
+    },
+    ("S4", "blue"): {
+        "title1": "S4 采购执行模块",
+        "title2": "业务蓝图设计方案",
+        "doc_no": "S4-BBP-001",
+        "author": "彭熙玉",
+        "created": "2026-06-11",
+        "updated": "2026-06-11",
+    },
+}
+
+REMOVE_HEADINGS = {"审核", "发布", "版本记录"}
+REMOVE_LINE_PREFIXES = (
+    "文档作者:",
+    "创建日期:",
+    "更新日期:",
+    "当前版本:",
+    "文档编号:",
+    "版本:",
+)
+
+
+def body_blocks(doc: Document):
+    body = doc.element.body
+    for child in body:
+        if child.tag.endswith("p"):
+            yield "p", Paragraph(child, doc)
+        elif child.tag.endswith("tbl"):
+            yield "t", Table(child, doc)
+
+
+def block_text(kind: str, obj) -> str:
+    if kind == "p":
+        return obj.text.strip()
+    return ""
+
+
+def is_change_log_table(table: Table) -> bool:
+    if not table.rows:
+        return False
+    header = "".join(c.text.strip() for c in table.rows[0].cells)
+    return "变更说明" in header and ("版本" in header or "日期" in header)
+
+
+def is_cover_meta_table(table: Table) -> bool:
+    if not table.rows:
+        return False
+    return table.rows[0].cells[0].text.strip() == "文档编号"
+
+
+def is_audit_or_release_table(table: Table) -> bool:
+    if not table.rows:
+        return False
+    header = [c.text.strip() for c in table.rows[0].cells]
+    return header[:3] == ["姓名", "职位", "签字/日期"] or header[:3] == ["编号", "名称", "地点"]
+
+
+def is_version_record_table(table: Table) -> bool:
+    if not table.rows:
+        return False
+    header = [c.text.strip() for c in table.rows[0].cells]
+    return header[:4] == ["版本", "日期", "修订人", "修订说明"]
+
+
+def set_cell(cell, text: str, *, bold: bool = False) -> None:
+    cell.text = ""
+    run = cell.paragraphs[0].add_run(text)
+    run.font.size = Pt(10.5)
+    run.bold = bold
+
+
+def build_cover_table(doc: Document, meta: dict) -> Table:
+    rows = [
+        ("文档编号", meta["doc_no"]),
+        ("版本", V02),
+        ("密级", "内部"),
+        ("编制单位", meta["author"]),
+        ("编制日期", meta["updated"]),
+    ]
+    table = doc.add_table(rows=len(rows), cols=2)
+    for ri, (label, value) in enumerate(rows):
+        set_cell(table.rows[ri].cells[0], label, bold=True)
+        set_cell(table.rows[ri].cells[1], value)
+    return table
+
+
+def build_change_table(doc: Document, meta: dict) -> Table:
+    table = doc.add_table(rows=3, cols=4)
+    headers = ["日期", "版本", "修订人", "变更说明"]
+    data = [
+        (meta["created"], V01, meta["author"], NOTE_V01),
+        (meta["updated"], V02, meta["author"], NOTE_V02),
+    ]
+    for ci, val in enumerate(headers):
+        set_cell(table.rows[0].cells[ci], val, bold=True)
+    for ri, row in enumerate(data, start=1):
+        for ci, val in enumerate(row):
+            set_cell(table.rows[ri].cells[ci], val)
+    return table
+
+
+def set_paragraph_text(p: Paragraph, text: str, style: str | None = None) -> None:
+    p.text = text
+    if style:
+        p.style = style
+
+
+def insert_paragraph_before(ref_el, doc: Document) -> Paragraph:
+    el = OxmlElement("w:p")
+    ref_el.addprevious(el)
+    return Paragraph(el, doc)
+
+
+def move_element_before(element, ref_el) -> None:
+    parent = element.getparent()
+    if parent is not None:
+        parent.remove(element)
+    ref_el.addprevious(element)
+
+
+def remove_orphan_tables_after_toc(doc: Document, toc_el) -> None:
+    body = doc.element.body
+    for tbl_el in list(body):
+        if not tbl_el.tag.endswith("tbl"):
+            continue
+        if list(body).index(tbl_el) <= list(body).index(toc_el):
+            continue
+        table = Table(tbl_el, doc)
+        if is_version_record_table(table):
+            body.remove(tbl_el)
+
+
+def unify_document(path: Path, module: str, kind: str) -> None:
+    meta = DOC_META[(module, kind)]
+    doc = Document(path)
+    blocks = list(body_blocks(doc))
+
+    toc_idx = next(
+        (i for i, (k, o) in enumerate(blocks) if k == "p" and block_text(k, o) == "目录"),
+        None,
+    )
+    if toc_idx is None:
+        raise RuntimeError(f"未找到目录: {path.name}")
+
+    toc_el = blocks[toc_idx][1]._element
+
+    for kind_b, obj in blocks[:toc_idx]:
+        if kind_b == "p":
+            obj._element.getparent().remove(obj._element)
+        elif kind_b == "t":
+            obj._tbl.getparent().remove(obj._tbl)
+
+    p1 = insert_paragraph_before(toc_el, doc)
+    p2 = insert_paragraph_before(toc_el, doc)
+    p3 = insert_paragraph_before(toc_el, doc)
+    move_element_before(p1._element, toc_el)
+    move_element_before(p2._element, toc_el)
+    move_element_before(p3._element, toc_el)
+
+    set_paragraph_text(p1, meta["title1"])
+    set_paragraph_text(p2, meta["title2"])
+    set_paragraph_text(p3, SYSTEM_NAME)
+
+    insert_paragraph_before(toc_el, doc)
+    cover = build_cover_table(doc, meta)
+    move_element_before(cover._tbl, toc_el)
+    insert_paragraph_before(toc_el, doc)
+
+    h1 = insert_paragraph_before(toc_el, doc)
+    set_paragraph_text(h1, "文档控制", "Heading 1")
+    h2 = insert_paragraph_before(toc_el, doc)
+    set_paragraph_text(h2, "更改记录", "Heading 2")
+    insert_paragraph_before(toc_el, doc)
+    change = build_change_table(doc, meta)
+    move_element_before(change._tbl, toc_el)
+    insert_paragraph_before(toc_el, doc)
+
+    remove_orphan_tables_after_toc(doc, toc_el)
+    doc.save(path)
+
+
+def main() -> None:
+    for module in ["S0", "S1", "S2", "S3", "S4"]:
+        mod_dir = BASE / module
+        for path in sorted(mod_dir.glob("*.docx")):
+            kind = "blue" if "蓝图" in path.name else "req"
+            unify_document(path, module, kind)
+            print(f"OK {module}/{path.name}")
+
+
+if __name__ == "__main__":
+    main()

+ 243 - 0
doc/_unify_version_records.py

@@ -0,0 +1,243 @@
+# -*- coding: utf-8 -*-
+"""Unify version records to V0.1 初版建立 + V0.2 增加版本号 in delivery docx."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from docx import Document
+from docx.shared import Pt
+
+BASE = Path(
+    r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
+)
+V01 = "V0.1"
+V02 = "V0.2"
+NOTE_V01 = "初版建立"
+NOTE_V02 = "增加版本号"
+
+
+def parse_metadata(doc: Document) -> dict[str, str]:
+    meta = {"author": "智造易项目组", "created": "2026-06-10", "updated": "2026-06-10"}
+    for p in doc.paragraphs[:25]:
+        t = p.text.strip()
+        if t.startswith("文档作者:"):
+            meta["author"] = t.split(":", 1)[1].strip()
+        if t.startswith("创建日期:"):
+            meta["created"] = t.split(":", 1)[1].strip()
+            meta["_has_created"] = True
+        elif t.startswith("更新日期:"):
+            meta["updated"] = t.split(":", 1)[1].strip()
+        elif t.startswith("版本:"):
+            m = re.search(r"作者:\s*(.+)$", t)
+            if m:
+                meta["author"] = m.group(1).strip()
+            m = re.search(r"日期:\s*([^作者]+?)(?:\s+作者:|$)", t)
+            if m:
+                d = normalize_date(m.group(1).strip())
+                meta["updated"] = d
+                if not meta.get("_has_created"):
+                    meta["created"] = d
+    if doc.tables:
+        for row in doc.tables[0].rows:
+            cells = [c.text.strip() for c in row.cells]
+            if len(cells) >= 2 and cells[0] == "编制单位" and cells[1]:
+                meta["author"] = cells[1]
+            if len(cells) >= 2 and cells[0] == "版本" and cells[1].startswith("V"):
+                pass
+    return meta
+
+
+def normalize_date(text: str) -> str:
+    text = text.strip()
+    m = re.match(r"(\d{4})年(\d{1,2})月(\d{1,2})日", text)
+    if m:
+        y, mo, d = m.groups()
+        return f"{y}-{int(mo):02d}-{int(d):02d}"
+    m = re.match(r"(\d{4})/(\d{1,2})/(\d{1,2})", text)
+    if m:
+        y, mo, d = m.groups()
+        return f"{y}-{int(mo):02d}-{int(d):02d}"
+    return text
+
+
+def set_cell_text(cell, text: str, *, bold: bool = False) -> None:
+    cell.text = ""
+    run = cell.paragraphs[0].add_run(text)
+    run.font.size = Pt(10.5)
+    run.bold = bold
+
+
+def resize_table_rows(table, data_row_count: int) -> None:
+    target = data_row_count + 1
+    while len(table.rows) > target:
+        table._tbl.remove(table.rows[-1]._tr)
+    while len(table.rows) < target:
+        table.add_row()
+
+
+def find_paragraph(doc: Document, text: str) -> int | None:
+    for i, p in enumerate(doc.paragraphs):
+        if p.text.strip() == text:
+            return i
+    return None
+
+
+def find_change_log_table(doc: Document):
+    idx = find_paragraph(doc, "更改记录")
+    if idx is None:
+        return None
+    body = list(doc.element.body)
+    pos = body.index(doc.paragraphs[idx]._element)
+    for el in body[pos + 1 : pos + 10]:
+        if not el.tag.endswith("tbl"):
+            continue
+        for table in doc.tables:
+            if table._tbl is not el:
+                continue
+            header = [c.text.strip() for c in table.rows[0].cells]
+            header_text = "".join(header)
+            if "变更说明" in header_text and ("版本" in header_text or "日期" in header_text):
+                return table
+    return None
+
+
+def table_after_heading(doc: Document, heading: str):
+    idx = find_paragraph(doc, heading)
+    if idx is None:
+        return None
+    body = list(doc.element.body)
+    pos = body.index(doc.paragraphs[idx]._element)
+    for el in body[pos + 1 : pos + 6]:
+        if el.tag.endswith("tbl"):
+            for table in doc.tables:
+                if table._tbl is el:
+                    return table
+    return None
+
+
+def standard_version_rows(meta: dict[str, str]) -> list[tuple[str, str, str, str]]:
+    return [
+        (V01, meta["created"], meta["author"], NOTE_V01),
+        (V02, meta["updated"], meta["author"], NOTE_V02),
+    ]
+
+
+def fill_standard_version_table(table, meta: dict[str, str]) -> None:
+    rows = standard_version_rows(meta)
+    resize_table_rows(table, len(rows))
+    headers = ["版本", "日期", "修订人", "修订说明"]
+    for ci, val in enumerate(headers):
+        set_cell_text(table.rows[0].cells[ci], val, bold=True)
+    for ri, row_data in enumerate(rows, start=1):
+        for ci, val in enumerate(row_data):
+            set_cell_text(table.rows[ri].cells[ci], val)
+
+
+def fill_change_log_table(table, meta: dict[str, str]) -> None:
+    header_cells = [c.text.strip() for c in table.rows[0].cells]
+    if "变更说明" not in header_cells and "修订说明" not in "".join(header_cells):
+        return
+    rows = standard_version_rows(meta)
+    resize_table_rows(table, len(rows))
+    # 日期 | 版本 | 修订人/姓名 | 变更说明
+    name_col = "姓名" if "姓名" in header_cells else "修订人"
+    mapping = {
+        "日期": lambda r: r[1],
+        "版本": lambda r: r[0],
+        name_col: lambda r: r[2],
+        "修订人": lambda r: r[2],
+        "姓名": lambda r: r[2],
+        "变更说明": lambda r: r[3],
+        "修订说明": lambda r: r[3],
+    }
+    for ci, title in enumerate(header_cells):
+        if title in ("版本", "日期", "修订人", "姓名", "变更说明", "修订说明"):
+            set_cell_text(table.rows[0].cells[ci], title, bold=True)
+    for ri, row_data in enumerate(rows, start=1):
+        for ci, title in enumerate(header_cells):
+            fn = mapping.get(title)
+            if fn:
+                set_cell_text(table.rows[ri].cells[ci], fn(row_data))
+
+
+def update_cover_version(doc: Document, meta: dict[str, str]) -> int:
+    changed = 0
+    for p in doc.paragraphs[:25]:
+        t = p.text.strip()
+        if t.startswith("当前版本:"):
+            new = f"当前版本:{V02}"
+            if p.text != new:
+                if p.runs:
+                    p.runs[0].text = new
+                    for r in p.runs[1:]:
+                        r.text = ""
+                else:
+                    p.add_run(new)
+                changed += 1
+        elif t.startswith("版本:"):
+            new = f"版本:{V02}    日期:{format_cn_date(meta['updated'])}    作者:{meta['author']}"
+            if p.text.strip() != new:
+                if p.runs:
+                    p.runs[0].text = new
+                    for r in p.runs[1:]:
+                        r.text = ""
+                else:
+                    p.add_run(new)
+                changed += 1
+    if doc.tables:
+        row0 = doc.tables[0].rows
+        for row in row0:
+            cells = [c.text.strip() for c in row.cells]
+            if len(cells) >= 2 and cells[0] == "版本" and cells[1].startswith("V"):
+                if cells[1] != V02:
+                    set_cell_text(row.cells[1], V02)
+                    changed += 1
+    return changed
+
+
+def format_cn_date(iso_date: str) -> str:
+    m = re.match(r"(\d{4})-(\d{2})-(\d{2})", iso_date)
+    if not m:
+        return iso_date
+    y, mo, d = m.groups()
+    return f"{y}年{int(mo)}月{int(d)}日"
+
+
+def process_file(path: Path) -> None:
+    doc = Document(path)
+    meta = parse_metadata(doc)
+    meta["updated"] = normalize_date(meta["updated"])
+    meta["created"] = normalize_date(meta["created"])
+
+    cover_changes = update_cover_version(doc, meta)
+
+    version_table = table_after_heading(doc, "版本记录")
+    version_updated = False
+    if version_table is not None:
+        fill_standard_version_table(version_table, meta)
+        version_updated = True
+
+    change_table = find_change_log_table(doc)
+    change_updated = False
+    if change_table is not None:
+        fill_change_log_table(change_table, meta)
+        change_updated = True
+
+    doc.save(path)
+    print(
+        f"{path.name}: cover={cover_changes} "
+        f"版本记录={'Y' if version_updated else 'N'} "
+        f"更改记录={'Y' if change_updated else 'N'}"
+    )
+
+
+def main() -> None:
+    for mod in ["S0", "S1", "S2", "S3", "S4"]:
+        mod_dir = BASE / mod
+        for path in sorted(mod_dir.glob("*.docx")):
+            process_file(path)
+
+
+if __name__ == "__main__":
+    main()

+ 83 - 0
doc/_verify_s1_s4_extra_smoke.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+import json
+import urllib.request
+from gmssl import sm2
+
+BASE = "http://127.0.0.1:5005"
+TENANT = 797403760988229
+pk = "84C7466D950E120E5ECE5DD85D0C90EAA85081A3A2BD7C57AE6DC822EFCCBD66620C67B0103FC8DD280E36C3B282977B722AAEC3C56518EDCEBAFB72C5A05312"
+crypt = sm2.CryptSM2(public_key=pk, private_key=None, mode=1)
+pwd = crypt.encrypt(b"1234567890dop").hex()
+
+
+def http(method, path, body=None, token=None):
+    headers = {"Content-Type": "application/json", "Accept": "application/json"}
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+    data = None if body is None else json.dumps(body).encode()
+    req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=120) as r:
+            raw = r.read().decode()
+            return r.status, json.loads(raw) if raw else {}
+    except Exception as e:
+        if hasattr(e, "read"):
+            raw = e.read().decode("utf-8", "replace")
+            try:
+                return e.code, json.loads(raw)
+            except Exception:
+                return getattr(e, "code", 0), {"raw": raw[:500]}
+        return 0, {"error": str(e)}
+
+
+st, body = http(
+    "POST",
+    "/api/sysAuth/login",
+    {"account": "AIDOPDemo", "password": pwd, "tenantId": str(TENANT)},
+)
+token = (body.get("result") or {}).get("accessToken")
+assert token, body
+domain = str(TENANT)
+
+apis = [
+    ("GET", "/api/Order/seorder/list?billNo=SO202607120001&page=1&pageSize=5", None),
+    ("GET", "/api/Order/delivery/list?billNo=SO202607120001&page=1&pageSize=10", None),
+    ("GET", f"/api/Production/scheduling/list?page=1&pageSize=5&domain={domain}", None),
+    ("POST", "/api/Production/scheduling/sync-routing", {"workOrd": "M000000005", "domain": domain}),
+    ("GET", f"/api/Production/scheduling/materials?workOrd=M000000005&domain={domain}", None),
+    ("GET", f"/api/Production/scheduling/routings?workOrd=M000000005&domain={domain}", None),
+    ("GET", "/api/Supply/demand-schedule/list?page=1&pageSize=5", None),
+    ("GET", "/api/Supply/purchase-request/list?page=1&pageSize=5", None),
+    ("GET", "/api/Supply/purchase-order/list?page=1&pageSize=5", None),
+    ("POST", "/api/WorkOrder/dispatch/kitting-check", {"workOrd": "M000000005", "tenantId": TENANT}),
+    ("GET", "/api/ProcurementExecution/supplier-shipment/list?page=1&pageSize=5", None),
+    ("POST", f"/api/Production/scheduling/generate?domain={domain}", None),
+]
+
+out = []
+for method, path, body in apis:
+    st, b = http(method, path, body, token)
+    res = b.get("result", b) if isinstance(b, dict) else b
+    brief = {}
+    if isinstance(res, dict):
+        for k in ("total", "message", "Message", "list", "workOrd", "count", "enabled", "scheduleRowCount", "workOrderCount"):
+            if k in res:
+                brief[k] = len(res[k]) if k == "list" and isinstance(res[k], list) else res[k]
+        if not brief:
+            brief = {"keys": list(res.keys())[:8], "code": b.get("code"), "msg": b.get("message")}
+    else:
+        brief = {"value": str(res)[:200]}
+    out.append(
+        {
+            "api": f"{method} {path.split('?')[0]}",
+            "http": st,
+            "ok": st == 200 and b.get("code", 200) in (200, None),
+            "brief": brief,
+            "err": b.get("message") or b.get("raw") or b.get("error"),
+        }
+    )
+
+path = r"d:\Projects\Ai-DOP\SourceCode\ZZYDOP\doc\_verify_s1_s4_extra_smoke_result.json"
+with open(path, "w", encoding="utf-8") as f:
+    json.dump(out, f, ensure_ascii=False, indent=2, default=str)
+print(json.dumps(out, ensure_ascii=False, indent=2, default=str))

+ 160 - 0
doc/feishu/MCP集成说明.md

@@ -0,0 +1,160 @@
+# 飞书文档 MCP 集成说明
+
+让 Cursor Agent 能读写飞书云盘与在线文档,配合本仓库 `doc/` 下 Word 批处理脚本使用。
+
+---
+
+## 架构:双 MCP
+
+| MCP 服务 | 包/路径 | 适用文档 |
+|----------|---------|----------|
+| **feishu-drive**(本仓库自建) | `tools/feishu-drive-mcp/` | 云盘中的 **Word / Excel / PPT**(上传的 `.docx/.xlsx/.pptx` 等) |
+| **feishu-openapi**(飞书官方) | `@larksuiteoapi/lark-mcp` | **飞书在线文档**(docx / 表格 / 多维表格等 Block 结构) |
+
+官方 OpenAPI MCP **不支持**云盘文件二进制上传/下载,因此 Office 文件需用自建 `feishu-drive`。
+
+---
+
+## 一、飞书开放平台准备
+
+1. 打开 [飞书开放平台](https://open.feishu.cn/app) → **创建企业自建应用**。
+2. **权限**(按实际文档类型勾选,至少包含):
+
+   | 权限 scope | 用途 |
+   |------------|------|
+   | `drive:drive` 或 `drive:drive:readonly` + 上传相关 | 云盘列表、下载、上传 |
+   | `docx:document` | 在线文档读写在官方 MCP 中使用 |
+   | `docs:document.media:download` | 导出/素材(按需) |
+
+3. **发布应用**并完成企业管理员授权。
+4. 将目标云盘文件夹 **分享给应用**(或把应用 bot 加入有权限的群组/空间)。
+5. 记录 **App ID**、**App Secret**;文件夹 token 为链接中 `folder/` 后一段,例如:  
+   `https://xxx.feishu.cn/drive/folder/Ks1RfygMblbsNEdB3JdcxHCnnFb` → `Ks1RfygMblbsNEdB3JdcxHCnnFb`
+
+**切勿**将 App Secret 提交到 Git;仅放在本机 `~/.cursor/mcp.json` 或环境变量中。
+
+---
+
+## 二、安装 feishu-drive MCP
+
+```powershell
+cd D:\Projects\Ai-DOP\SourceCode\ZZYDOP\tools\feishu-drive-mcp
+npm install
+```
+
+复制环境变量示例(仅供本地参考,勿提交真实密钥):
+
+```powershell
+copy .env.example .env
+# 编辑 .env 填入 FEISHU_APP_ID / FEISHU_APP_SECRET
+```
+
+---
+
+## 三、配置 Cursor MCP
+
+编辑 **`C:\Users\skygu\.cursor\mcp.json`**,合并 [`.cursor/mcp.feishu.example.json`](../.cursor/mcp.feishu.example.json) 中的两个服务块。
+
+### 1. feishu-drive(云盘 Office 文件)
+
+```json
+"feishu-drive": {
+  "command": "node",
+  "args": [
+    "D:\\Projects\\Ai-DOP\\SourceCode\\ZZYDOP\\tools\\feishu-drive-mcp\\server.js"
+  ],
+  "env": {
+    "FEISHU_APP_ID": "cli_xxx",
+    "FEISHU_APP_SECRET": "xxx",
+    "FEISHU_DEFAULT_FOLDER_TOKEN": "Ks1RfygMblbsNEdB3JdcxHCnnFb",
+    "FEISHU_WORKSPACE_ROOT": "D:\\Projects\\Ai-DOP\\SourceCode\\ZZYDOP"
+  }
+}
+```
+
+### 2. feishu-openapi(在线文档)
+
+首次需 OAuth 登录(在终端执行一次):
+
+```powershell
+npx -y @larksuiteoapi/lark-mcp login -a cli_xxx -s xxx -d https://open.feishu.cn/
+```
+
+MCP 配置示例:
+
+```json
+"feishu-openapi": {
+  "command": "npx",
+  "args": [
+    "-y",
+    "@larksuiteoapi/lark-mcp",
+    "mcp",
+    "-a", "cli_xxx",
+    "-s", "xxx",
+    "-d", "https://open.feishu.cn",
+    "--oauth",
+    "--token-mode", "user_access_token",
+    "-t", "preset.doc.default,preset.drive.default"
+  ]
+}
+```
+
+保存后 **重启 Cursor**,在 MCP 面板确认 `feishu-drive`、`feishu-openapi` 为绿色可用。
+
+---
+
+## 四、Agent 工具说明(feishu-drive)
+
+| 工具 | 作用 |
+|------|------|
+| `feishu_list_folder` | 列出文件夹内文件(含 token、类型) |
+| `feishu_get_file_meta` | 查询单个文件元信息 |
+| `feishu_download_file` | 下载云盘 Office 文件到工作区 |
+| `feishu_upload_file` | 上传本地文件;可用 `replace_file_token` 覆盖旧文件 |
+| `feishu_export_online_doc` | 在线 doc/表格/幻灯片 → 导出 docx/xlsx/pptx/pdf 再下载 |
+| `feishu_delete_file` | 删除云盘文件(慎用) |
+
+---
+
+## 五、典型工作流
+
+### A. 修改云盘上的 Word(.docx)
+
+1. `feishu_list_folder` → 找到 `file_token`
+2. `feishu_download_file` → 保存到 `doc/.feishu-cache/xxx.docx`
+3. Agent 运行 `doc/_extend_delivery_front_matter.py` 等脚本或 python-docx 修改
+4. `feishu_upload_file` + `replace_file_token` 覆盖飞书原文件
+
+### B. 修改飞书在线文档
+
+1. 用 **feishu-openapi** 官方工具读取/更新 Block(如 `docx.v1.document.rawContent` 等,以 MCP 暴露的工具名为准)
+2. 若需整篇 Word 级版式(分页、表格),可先 `feishu_export_online_doc` 导出 docx → 本地改 → 上传为新文件(在线 doc 无法直接替换成 docx,需业务上约定)
+
+### C. Excel / PPT
+
+- **云盘文件**:`download` → 本地改 → `upload` + `replace_file_token`
+- **在线表格/幻灯片**:`feishu_export_online_doc`(xlsx/pptx)或官方 MCP 表格/幻灯片 API
+
+---
+
+## 六、与本仓库文档脚本配合
+
+本地批处理脚本仍在 `doc/` 下,例如:
+
+- `_extend_delivery_front_matter.py` — 封面/版本记录/目录分页
+- `_add_req_func_codes.py` — REQ/FUNC 编号
+
+推荐缓存目录:`doc/.feishu-cache/`(已在 `.gitignore` 忽略)。
+
+---
+
+## 七、故障排查
+
+| 现象 | 处理 |
+|------|------|
+| 403 / 99991663 无权限 | 检查应用权限是否开通、是否已发布、文件夹是否已分享给应用 |
+| 下载失败 | 确认类型为 `file`;在线 doc 请用 `feishu_export_online_doc` |
+| 官方 MCP 无文件工具 | 预期行为;Office 二进制走 `feishu-drive` |
+| OAuth 过期 | 重新执行 `lark-mcp login` |
+
+更多官方文档:[OpenAPI MCP 概述](https://open.feishu.cn/document/mcp_open_tools/mcp-overview)

BIN
doc/plan/AI-DOP报价清单_20260628_模块三行扩展.xlsx


+ 122 - 0
doc/plan/AIDOPB租户UAT就绪与补数任务书.md

@@ -0,0 +1,122 @@
+# AIDOPB 租户 UAT 就绪与补数任务书
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 让脱敏测试租户 **AIDOPB** 能完成主要功能 UAT,且看板/KPI 均由**本租户自己产生/预置的数据**算出(交付标准) |
+| 编写日期 | 2026-07-22 |
+| 租户 | AIDOPB `TenantId=824585161322565`;机构 `OrgId=824585161523269`;账号 `AIDOPDemoB` / `AIDopAdminB` |
+| 对照租户 | Demo `797403760988229`(现有全量数据的参考租户) |
+| 背景 | 见 [`AIDOP双模式架构与第三方对接现状基线.md`](./AIDOP双模式架构与第三方对接现状基线.md);KPI 现状为 Demo 中心 + S5–S7 写死默认租户 |
+| 关联待办 | P-001(T-MDP 同步配置中心)、本任务书专题 T-UATB |
+
+---
+
+## 0. 现状结论(体检结果)
+
+- **已就绪**:S0 主数据(物料/供应商/BOM/产线/人员/设备)、S5 委外域(委外订单 4824、委外交付 267 万行)、九宫格配置(已复制)。
+- **缺失**:S1–S4/S6 的业务事务单全为 0 → `ado_s9_kpi_value_l1_day` 为 0 → 九宫格有格式无数据。
+- **两类根因**:
+  1. AIDOPB 没有 S1–S4 事务数据(订单/工单/采购/收发货)。
+  2. **S5/S6/S7 KPI 服务把输出 `tenant_id` 写死 `1300000000001`**(缺陷),真实租户永远出不了 S5–S7。
+
+---
+
+## 1. 执行策略(交付标准)
+
+| 线 | 内容 | 方式 |
+|----|------|------|
+| **线 A** | S5/S6/S7 KPI 租户写死缺陷修复 | 改代码(升后端版本号) |
+| **线 B1** | S1–S3 + S4 发货:系统内**手工新建**,顺带做功能 UAT | 界面操作(AIDOPB 账号) |
+| **线 B2** | S4 收货 / S5 入库·退料·委外发料 / IQC:**预置少量数据** | 脚本/SQL 灌 AIDOPB 租户 |
+
+---
+
+## 2. 线 A — 缺陷修复清单(S5–S7 KPI 租户参数化)
+
+> 目标:把 KPI 落库租户从写死 `1300000000001` 改为**按刷新目标租户参数化**,使真实租户(含 AIDOPB、正式客户)能产出 S5–S7 指标。
+
+**待改文件与位置**(`tenant_id` 硬编码处):
+
+| 文件 | 硬编码处数 | 说明 |
+|------|-----------|------|
+| `MaterialWarehouse/S5MdpSyncTransformService.cs` | 7 | KPI 值表 / 运行日志 / 最大 Id 查询等 |
+| `Manufacturing/S6MdpSyncTransformService.cs` | 5 | 同类 |
+| `FinishedWarehouse/S7MdpSyncTransformService.cs` | 6 | 同类 |
+
+**改造要点(拟定,待评审)**:
+
+1. 在 `S5/6/7MdpRefreshOption`(或 `RunFullAsync` 入参)增加 `TargetTenantId`、`TargetFactoryId`(默认仍 `1300000000001/1`,保持既有行为不破坏)。
+2. 把 KPI 值 INSERT、`mdp_transform_run_log`、`ado_s9_kpi_value_l1_day` 最大 Id 查询里的字面量 `1300000000001` 换成参数。
+3. `S5/6/7MdpRefreshJob` 与 `AidopKanbanController` 手动 refresh 入口支持传目标租户;不传则维持默认。
+4. **注意**:T8 源 `ztid`(`option.SourceZtid`)与 DOP 租户是两个维度——需明确 **AIDOPB 对应的 T8 `ztid`**(若 AIDOPB 无对应 T8 账套,则 S5–S7 走"预置/造数"而非 T8 实时)。
+
+**风险/影响面**:
+- 影响 S5–S7 KPI 落库租户;默认参数保证 Demo/现有行为不变。
+- 跨模块:仅 S5–S7 三个 Service + 各自 Job + 看板 refresh 入口;不动 S1–S4。
+- 需与"AIDOPB 是否有 T8 账套"决策联动。
+
+> **本线为代码改动,按 `collaboration-scope` 需你确认后再改;改动落 `server/`,将按规则递增后端版本号。**
+
+---
+
+## 3. 线 B1 — 系统内手工新建 UAT 操作清单
+
+> 用 `AIDOPDemoB` 登录,按顺序操作;每步既补数据又验证功能。
+
+| # | 步骤 | 菜单 / 入口 | 后端 | 预期结果 |
+|---|------|------------|------|----------|
+| 1 | 新建销售订单 | S1 订单管理 → 订单 → 添加 | `POST /api/Order/seorder/save` | `crm_seorder` 出现 AIDOPB 订单 |
+| 2 | 订单评审 | 订单列表 →「订单评审」 | `POST /api/Order/seorder/review` | 缺料自动生成工单 `WorkOrdMaster`;评审即时返回 |
+| 3 | 交期确认 | 订单列表 →「交期确认」 | `POST /api/Order/seorder/confirm-delivery` | 工单转待排产 `p` |
+| 4 | 3 级计划重排 | 订单 →「3级计划重排」 | `POST /api/Order/seorder/{id}/refresh-plan` | 自动带出:排程 + MRP → 采购申请 → 采购订单 |
+| 5 | (可选)合同评审 | S1 → 合同评审 → 新建 | `POST /api/Order/contract/save` | 独立单据,测审批流 |
+| 6 | (可选)产品设计 | S1 → 产品设计 → 新建 | `POST /api/Order/productdesign/save` | 独立单据 |
+| 7 | (可选)采购申请/要货令/委外单 | S3 各列表 → 添加 | `purchase-request/save`、`demand-order/save`、`outsource-order/save` | 手工补 S3 单据 |
+| 8 | S4 供应商发货 | S4 → 交货 → 供应商发货 → 添加 | `POST /api/ProcurementExecution/supplier-shipment/save` | `scm_shd` 出现 AIDOPB 发货单 |
+
+完成后**手动触发 S1–S4 MDP 刷新**(或等定时),验证 S1–S4 九宫格出真实值。
+
+---
+
+## 4. 线 B2 — 需预置数据的表清单(系统内不可新建)
+
+> 这些环节真实生产靠源系统同步;UAT 需预置**少量** AIDOPB 租户数据。
+
+| 环节 | 表 | 租户字段 | 预置说明 |
+|------|----|----------|----------|
+| S4 采购收货 | `PurOrdRctMaster` / `PurOrdRctDetail`(`RctType='rc'`) | `tenant_id` ✅ | 造几条 rc 收货单(可关联线 B1 生成的 PO),供 S4/S5 收货列表、采购退货、IQC 用 |
+| S5 生产入库/退料 | `NbrMaster` / `NbrDetail`(`Type='WOI'/'WOD'`) | `tenant_id` ✅ | 造少量,供 S5 生产入库/退料列表 |
+| S5 委外发料 | `NbrMaster` / `NbrDetail`(`Type='CA'`) | `tenant_id` ✅ | 委外域已有海量数据,按需少量补 |
+| S5 IQC 来料检验 | `qms_qcp_inspbill`(+ 申请/待检 `qms_qcp_*`) | **无租户列** ⚠️ | 造几条检验单,供 IQC 检验流程 UAT;**多租户隔离需另行确认**(该表全库共享) |
+| S6 过程检验 | `qms_gcjyd` | **无租户列** ⚠️ | 当前空表;造少量供 IPQC UAT |
+
+**待决策**:`qms_qcp_inspbill` / `qms_gcjyd` 无租户字段,AIDOPB 与其它租户的 IQC/IPQC 数据如何隔离(是否按机构/工厂/单号前缀区分),需在预置前定口径。
+
+---
+
+## 5. 建议执行顺序
+
+1. **先定 2 个前置决策**:
+   - AIDOPB 是否有对应的 **T8 账套 `ztid`**(决定 S5–S7 走实时 T8 还是造数)。
+   - IQC/IPQC 无租户列的**隔离口径**。
+2. **线 A 缺陷修复**(出补丁 → 评审 → 改 → 升版本)。
+3. **线 B1 手工建单**(UAT 主体)。
+4. **线 B2 预置数据**(补齐不可新建环节)。
+5. 对 AIDOPB 跑全模块 MDP,验收 S1–S9 九宫格。
+
+---
+
+## 6. 非目标范围(本轮不做)
+
+- 不改 S1–S4 既有 KPI/评审算法。
+- 不建 MES/WMS/QMS 的真实源系统同步链路(属 P-001 后续)。
+- 不改 Demo 租户任何数据。
+- 不把 Demo 的 KPI 值复制给 AIDOPB(拒绝假数据)。
+
+---
+
+## 7. 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-07-22 | 初版:AIDOPB UAT 就绪度体检结论 + 线 A(S5–S7 租户参数化缺陷)/ 线 B1(手工建单清单)/ 线 B2(预置数据表清单)|

+ 330 - 0
doc/plan/AIDOP双模式对接执行任务书.md

@@ -0,0 +1,330 @@
+# AIDOP 双模式第三方对接执行任务书(第三方大模型照做版)
+
+| 项 | 内容 |
+|----|------|
+| 文档定位 | **可直接执行**:把 [`AIDOP双模式落地完整实施方案.md`](./AIDOP双模式落地完整实施方案.md) 落成"读得懂就能做"的分步任务,供第三方大模型/开发按序执行 |
+| 编写日期 | 2026-07-22 |
+| 适用执行者 | 大语言模型编码代理、后端/前端开发 |
+| 上游 | 完整方案(WP/里程碑)、[`数据中台同步配置中心总体方案.md`](./数据中台同步配置中心总体方案.md)、[`数据中台模块扩展指南-S1至S3经验版.md`](./数据中台模块扩展指南-S1至S3经验版.md) |
+| 硬性约束 | 遵守 [`.cursor/rules/collaboration-scope.mdc`](../../.cursor/rules/collaboration-scope.mdc)(改动前列清单等确认)、[`version-bump-on-commit.mdc`](../../.cursor/rules/version-bump-on-commit.mdc)(按实际提交端升版本) |
+
+---
+
+## 0. 执行者须知(务必先读)
+
+1. **不要重造配置表**:`mdp_source`、`mdp_entity`、`mdp_sync_task/_step/_schedule` 已存在(见 `server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/`)。任务只新增**执行器**与**少量表**。
+2. **每个任务卡**含:目标 / 前置 / 拟改文件 / 步骤 / 完成标准 / 验收 / 是否需确认。凡标「需确认=是」,**先列清单等人确认再动**。
+3. **不破坏现状**:T8 现有 KPI 行为、Demo 租户结果必须回归一致;改造用"默认参数兜底"。
+4. **独立作业模式不能断**:无外部源时,业务模块仍要能本库跑(对接是叠加,不是替换)。
+5. **技术栈**:后端 .NET + Furion + Admin.NET + SqlSugar(MySQL 主库 `aidopdev`,多库 `ConfigId`);前端 Vue3 + Element Plus + Vite。
+6. **命名约定**:作业码 `Sx_MDP_SYNC_TRANSFORM`;刷新 API `POST /api/AidopKanban/sx-mdp/refresh`;指标码 `Sx_L1_001`;动态源 ConfigId `mdp-src-{sourceCode}`。
+
+---
+
+## 1. 关键既有资产(直接复用)
+
+| 资产 | 位置 | 用途 |
+|------|------|------|
+| `mdp_source` 表 | 库 `aidopdev` | 源系统连接(DB 段 + API 段 + 健康检查) |
+| `mdp_entity` 表 | 库 `aidopdev` | 实体登记:`source_table_name`(甲)、`source_api_path/api_config_id/response_data_path/dedup_key_path/last_cursor`(乙)、`incr_column/sync_mode/batch_size` |
+| `mdp_sync_task/_step/_schedule` 实体 | `Entity/DataPlatform/MdpSyncConfigEntities.cs` | 任务/步骤/调度业务配置 |
+| DB 直连范式 | `MaterialWarehouse/S5MdpSyncTransformService.cs:411` | `_db.AsTenant().GetConnectionScope("t8_v5").Ado.SqlQueryAsync<T>(sql, p)` |
+| S8 动态连接工厂(仿制样板) | `Service/S8/Rules/S8SqlSugarScopeFactory.cs` | 按数据源动态建 SqlSugar 连接 |
+| 转换服务范式 | `Order/S1MdpSyncTransformService.cs`、`Supply/S3MdpSyncTransformService.cs` | `RunFullAsync/SyncStaging/BuildStandard/BuildDwd/BuildKpi` |
+| 看板/刷新入口 | `Controllers/AidopKanbanController.cs` | 手动 refresh API |
+
+---
+
+## 2. `mdp_source` / `mdp_entity` 字段速查(执行者填配置用)
+
+### mdp_source(源系统连接)
+
+| 字段 | 说明 |
+|------|------|
+| `source_code` / `source_name` | 源编码/名称,如 `WMS_MAIN` |
+| `source_type` | `DB` / `API` |
+| `db_type` | `MYSQL` / `SQLSERVER` / `ORACLE` / `POSTGRES` |
+| `db_host/db_port/db_name/db_user/db_password_enc` | DB 连接(密码密文,解密后拼串) |
+| `db_extra_params` | 额外连接参数 |
+| `api_base_url` | API 基址 |
+| `api_auth_type` | `NONE/BASIC/BEARER/APIKEY/OAUTH2` |
+| `api_auth_config` | JSON 认证配置 |
+| `health_status/last_health_check/health_msg` | 健康检查 |
+
+### mdp_entity(同步实体)
+
+| 字段 | 甲/乙 | 说明 |
+|------|------|------|
+| `source_id` | 通用 | 指向 `mdp_source.id` |
+| `source_table_name` | 甲 | DB 源表/视图 |
+| `source_api_path` / `api_config_id` | 乙 | API 路径 / API 配置 |
+| `response_data_path` / `dedup_key_path` | 乙 | 响应数组 JSONPath / 去重键 |
+| `incr_column` / `last_cursor` / `last_sync_to` | 通用 | 增量列 / 水位游标 / 上次同步止点 |
+| `sync_mode` | 通用 | `FULL` / `INCREMENTAL` / `ROLLING_WINDOW` |
+| `batch_size` | 通用 | 批大小 |
+| `target_table_name` | 通用 | 目标 `mdp_stg_*` |
+
+---
+
+## 3. 平台任务卡(先做,所有模块共用)
+
+### 任务 P-A:统一动态连接工厂 `MdpSourceScopeFactory`(方式甲底座)
+
+- **目标**:按 `mdp_source` 动态建只读 SqlSugar 连接,替代写死 `t8_v5`。
+- **前置**:无。
+- **拟改文件**:新增 `DataPlatform/MdpSourceScopeFactory.cs`。
+- **步骤**:
+  1. 仿 `S8SqlSugarScopeFactory`:入参 `sourceCode`;读 `mdp_source` → 解密 `db_password_enc` → 按 `db_type` 拼连接串。
+  2. ConfigId 约定 `mdp-src-{sourceCode}`;`AsTenant().IsAnyConnection` 判断已注册则复用,否则 `AddConnection`(只读、`EnableInitDbInfo/EnableInitTables=false`)。
+  3. 暴露 `ISqlSugarClient GetScope(string sourceCode)`。
+- **完成标准**:能对任一 DB 型 `mdp_source` 取到可查询连接。
+- **验收**:单测/临时接口对 `T8_ERP` 源查一行返回。
+- **需确认**:否(新增类,不改现状)。
+
+### 任务 P-B:DB 抽数器 `MdpDbPullExecutor`(方式甲)
+
+- **目标**:`mdp_entity.source_table_name` → `mdp_stg_{entity}`,带增量水位。
+- **前置**:P-A。
+- **拟改文件**:新增 `DataPlatform/Executors/MdpDbPullExecutor.cs`。
+- **步骤**:
+  1. 用 P-A 连接,按 `incr_column > last_cursor` + `batch_size` 分批 `SELECT`。
+  2. 写 `target_table_name`(`mdp_stg_*`),含 `raw_data`(JSON)、`source_row_id`、`sync_batch_id`。
+  3. 更新 `mdp_entity.last_cursor/last_sync_to`;写 `mdp_sync_log`。
+- **完成标准**:对一张 DB 实体全量+增量各跑通一次。
+- **验收**:`SELECT COUNT(*) FROM mdp_stg_xxx`;`mdp_sync_log` 有成功记录。
+- **需确认**:否。
+
+### 任务 P-C:API 抽数器 `MdpApiPullExecutor`(方式乙)
+
+- **目标**:`mdp_source` API 段 + `mdp_entity` API 字段 → `mdp_stg_{entity}`。
+- **前置**:无(可与 P-A 并行)。
+- **拟改文件**:新增 `DataPlatform/Executors/MdpApiPullExecutor.cs`(用 `IHttpClientFactory`)。
+- **步骤**:
+  1. 认证:按 `api_auth_type` + `api_auth_config` 取 token/加 header。
+  2. 请求 `api_base_url + source_api_path`;分页/游标用 `last_cursor`。
+  3. 解析 `response_data_path`(JSONPath) 取数组;`dedup_key_path` 去重。
+  4. 写 `mdp_stg_{entity}`(同 P-B 结构),更新水位;写 `mdp_sync_log`。
+- **完成标准**:对一个 mock/真实 API 拉通并落 stg。
+- **验收**:stg 行数 > 0;重复拉取不产生重复(幂等)。
+- **需确认**:否。
+
+### 任务 P-D:统一执行器分发 `IMdpSourcePullExecutor`
+
+- **目标**:按 `task_type`/实体选择 DB 或 API 执行器。
+- **前置**:P-B、P-C。
+- **拟改文件**:新增接口 `IMdpSourcePullExecutor` + 分发器;`MdpDbPullExecutor`/`MdpApiPullExecutor` 实现之。
+- **完成标准**:给定 `mdp_entity` 自动走对应执行器。
+- **需确认**:否。
+
+### 任务 P-E:回写底座 `mdp_outbox` + `MdpApiPushExecutor`
+
+- **目标**:统一出站回写(Outbox + 推送 Job)。
+- **前置**:`mdp_source` API 段可用。
+- **拟改文件**:新增 DDL `mdp_outbox`;实体;`DataPlatform/Executors/MdpApiPushExecutor.cs`;Job `MdpOutboxPushJob`。
+- **DDL**:
+```sql
+CREATE TABLE IF NOT EXISTS mdp_outbox (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  target_source_code VARCHAR(100) NOT NULL,
+  action_code VARCHAR(100) NOT NULL,
+  idem_key VARCHAR(200) NOT NULL,
+  payload_json TEXT,
+  status TINYINT NOT NULL DEFAULT 0,      -- 0待推 1成功 2失败
+  retry_count INT NOT NULL DEFAULT 0,
+  response_json TEXT,
+  error_msg VARCHAR(1000),
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_idem (tenant_id, target_source_code, action_code, idem_key),
+  KEY idx_status (status, tenant_id)
+);
+```
+- **步骤**:动作 Service 只写 `mdp_outbox`(幂等键去重);Job 扫待推 → 取 `mdp_source` API 段 → 调写接口 → 记回执/状态;失败重试 `max_retry`,超限置失败+告警。
+- **完成标准**:一条 outbox 记录能被推送并回写状态;重复不重推。
+- **验收**:`SELECT status,COUNT(*) FROM mdp_outbox GROUP BY status`。
+- **需确认**:**是**(涉及外部写副作用,先确认目标系统与止损)。
+
+### 任务 P-F:配置中心真实化(数据任务页读真实任务)
+
+- **目标**:落配置中心方案 Phase 1–3;种子化 S1–S7 任务;页面读 `mdp_sync_task` + 最近运行。
+- **前置**:无。
+- **拟改文件**:`DataPlatform/MdpSyncTaskConfigService.cs` 等 + 前端数据中台页。
+- **完成标准**:数据任务页展示真实任务/调度/最近运行/状态。
+- **需确认**:是(改公共页面)。
+
+---
+
+## 4. 模块任务卡(S0–S9,甲/乙/回写)
+
+> 每模块统一按 6 步:①登记 `mdp_source` ②登记 `mdp_entity`(甲填 `source_table_name`,乙填 `source_api_path` 等)③建/确认 `mdp_stg_*`/`mdp_std_*` ④跑执行器(P-B/P-C)落 stg→std ⑤业务列表/看板读 std/dwd ⑥(如需)配 `mdp_outbox` 回写。
+
+### 通用模块步骤模板(复制到每个 Sx)
+
+- **任务 Sx-1 现状审计**(只读):确认该模块已有 `mdp_std_*`/`dwd_*`/KPI、业务列表读哪层。输出差距。**需确认=否**。
+- **任务 Sx-2 方案确认**:列拟改文件 + 跨模块影响 + 回滚点。**需确认=是**。
+- **任务 Sx-3 登记源与实体**:`INSERT mdp_source`(甲/乙二选一或都填)+ `INSERT mdp_entity`(对象逐条)。**需确认=是(DB 写)**。
+- **任务 Sx-4 建缺失分层表**:`CREATE TABLE IF NOT EXISTS mdp_stg_*/mdp_std_*`(见 §5 DDL 模板)。**需确认=是(DDL)**。
+- **任务 Sx-5 跑入站**:执行 P-D 分发器(甲=P-B / 乙=P-C)→ std 转换(复用/新增 `*MdpSyncService`)。**需确认=否**。
+- **任务 Sx-6 业务/看板接 std**:列表 Service 读 `mdp_std_*`;看板读 `dwd_*`/KPI。**需确认=否**。
+- **任务 Sx-7 回写(如适用)**:动作写 `mdp_outbox`;由 P-E 推送。**需确认=是(副作用)**。
+- **任务 Sx-8 验收+文档+提交**:跑 §6 验收 SQL;补 `doc/plan/数据库迁移/Sx/`;按规则升版本提交。
+
+### 各模块对象与源(照 §11 完整方案;此处给执行要点)
+
+| 模块 | 对象 | 甲源表 | 乙 API 路径示意 | 回写(outbox action) |
+|------|------|--------|----------------|---------------------|
+| **S0** | 物料/客户/供应商/人员/质量规范 | `ItemMaster`/`CustMaster`/`SuppMaster`/`EmployeeMaster`/`qms_*` | `/api/item` `/api/supplier` … | 无 |
+| **S1** | 销售订单/发货 | `crm_seorder*`/`ASNBOLShipper*` | `/api/salesorder` `/api/asn` | `S1_ORDER_STATUS_PUSH` |
+| **S2** | 工单/排程 | `WorkOrdMaster/Routing/Detail`/`ScheduleResultOpMaster` | `/api/workorder` `/api/schedule` | `S2_SCHEDULE_DISPATCH` |
+| **S3** | 采购申请/PO/交货 | `srm_pr_main`/`PurOrd*`/`srm_polist_ds` | `/api/pr` `/api/po` | `S3_PR_PUSH`(高风险,任务流) |
+| **S4** | 供应商发货/退货/IQC退货 | `scm_shd/zb`/退货表/`qms_qcp_insappnentry` | `/api/delivery` `/api/return` | `S4_RECEIPT_CONFIRM` |
+| **S5** | 收货/入库/退料/委外发料/IQC | `PurOrdRct*`/`Nbr*`/`qms_qcp_inspbill` | `/api/wms/receipt` `/api/mes/inbound` `/api/qms/iqc` | `S5_IQC_RESULT_PUSH` |
+| **S6** | 报工/IPQC/设备 | MES报工表/`qms_gcjyd`/`EquipmentList` | `/api/mes/report` `/api/qms/ipqc` | `S6_REPORT_PUSH` |
+| **S7** | 成品入库/发货/FQC | `NbrMaster(WOI)`/`ASNBOLShipperMaster`/`qms_qcpp_inspbill` | `/api/wms/fg-inbound` `/api/qms/fqc` | `S7_FQC_RESULT_PUSH` |
+| **S8** | 监视规则取数 | `ado_s8_data_source`(已支持) | 复用 P-C | 无(写 `ado_s8_exception`) |
+| **S9** | KPI 汇总/API_OUT | `ado_s9_kpi_value_*` | `/api/aidop/out/*`(受控只读) | 无 |
+
+> **S5 是首个样板**(详见附录 B),完成后 S6/S7 照抄仅换源表/实体。
+
+---
+
+## 5. DDL 模板(贴源/标准层)
+
+```sql
+-- 贴源层通用结构(每实体一张,或通用宽表)
+CREATE TABLE IF NOT EXISTS mdp_stg_<entity> (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  source_table VARCHAR(200),
+  source_row_id VARCHAR(200),
+  raw_data JSON,
+  sync_batch_id VARCHAR(100),
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  KEY idx_batch (sync_batch_id),
+  KEY idx_src (source_table, source_row_id),
+  KEY idx_tenant (tenant_id)
+);
+
+-- 标准层(示例:清洗后字段 + tenant + 业务键)
+CREATE TABLE IF NOT EXISTS mdp_std_<entity> (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  factory_id BIGINT,
+  biz_no VARCHAR(100),
+  item_code VARCHAR(100),
+  biz_date DATE,
+  qty DECIMAL(18,4),
+  status VARCHAR(40),
+  sync_batch_id VARCHAR(100),
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_biz (tenant_id, biz_no),
+  KEY idx_date (tenant_id, biz_date)
+);
+```
+
+要求:`CREATE TABLE IF NOT EXISTS`;唯一键支持 `ON DUPLICATE KEY UPDATE`;`tenant_id/biz_date` 建索引;标准层对空值/非法日期/非法数量兜底。
+
+---
+
+## 6. 验收 SQL
+
+```sql
+-- 源实体同步日志
+SELECT entity_code, status, MAX(create_time) FROM mdp_sync_log GROUP BY entity_code, status ORDER BY 3 DESC;
+-- 贴源→标准行数
+SELECT (SELECT COUNT(*) FROM mdp_stg_<entity>) stg, (SELECT COUNT(*) FROM mdp_std_<entity>) std;
+-- 转换批次
+SELECT job_code, status, stage_rows, standard_rows, dwd_rows, start_time, error_message
+FROM mdp_transform_run_log WHERE job_code='<Sx_MDP_SYNC_TRANSFORM>' ORDER BY start_time DESC LIMIT 5;
+-- KPI 落点(多租户校验)
+SELECT tenant_id, module_code, metric_code, COUNT(*) FROM ado_s9_kpi_value_l1_day
+WHERE module_code='<Sx>' GROUP BY tenant_id, module_code, metric_code;
+-- 回写状态
+SELECT target_source_code, status, COUNT(*) FROM mdp_outbox GROUP BY target_source_code, status;
+```
+
+---
+
+## 7. 缺陷修复任务卡(并行,交付阻断)
+
+### 任务 FIX-1:S5/S6/S7 KPI 租户参数化
+
+- **目标**:`1300000000001` 写死 → 按目标租户参数(S5 七处 / S6 五处 / S7 六处)。
+- **拟改文件**:`MaterialWarehouse/S5MdpSyncTransformService.cs`、`Manufacturing/S6MdpSyncTransformService.cs`、`FinishedWarehouse/S7MdpSyncTransformService.cs` + 各 `*MdpRefreshJob` + `AidopKanbanController` refresh 入口。
+- **步骤**:刷新 Option 加 `TargetTenantId/TargetFactoryId`(默认 `1300000000001/1`);KPI INSERT、`mdp_transform_run_log`、最大 Id 查询全部换参数;支持按租户遍历刷新。
+- **完成标准**:指定租户刷新后 `ado_s9_kpi_value_l1_day` 出该租户 S5–S7 行;不传参时 Demo 结果不变。
+- **验收**:上一节 KPI 多租户校验 SQL。
+- **需确认**:是(改运行 Service,升后端版本)。
+
+---
+
+## 8. 执行顺序(建议)
+
+1. **FIX-1**(交付阻断,独立可先做)。
+2. **P-A → P-B / P-C → P-D**(入站执行器底座)。
+3. **P-F**(配置中心真实化)。
+4. **S5(附录 B 样板)→ S6 → S7 → S3/S4 → S1/S2**(入站逐模块)。
+5. **P-E + 各模块 Sx-7**(回写,按风险从低到高)。
+6. **S9 API_OUT、S8 API 规则复用**(收尾)。
+
+每完成一模块跑 §6 验收并补验收文档。
+
+---
+
+## 附录 A:单模块执行 Prompt(喂给第三方大模型)
+
+```text
+请阅读并严格遵守:
+- doc/plan/AIDOP双模式对接执行任务书.md(本文件)
+- doc/plan/AIDOP双模式落地完整实施方案.md(第 10、11 节)
+- doc/plan/数据中台模块扩展指南-S1至S3经验版.md
+- .cursor/rules/collaboration-scope.mdc(改动前列清单等确认)
+
+目标模块:<Sx>
+对接方式:<方式甲 DB 直连 / 方式乙 API 拉取 / 两者>
+本轮边界:<入站取数 / 含回写 / 只读看板>
+
+要求:
+1. 先做 Sx-1 现状审计(只读),输出差距与跨模块影响。
+2. 出 Sx-2 方案确认清单,等我确认后再改。
+3. 复用 mdp_source/mdp_entity/mdp_sync_task* 与 MdpSourceScopeFactory/MdpDbPullExecutor/MdpApiPullExecutor,不要另造配置表或第二套连接/HTTP 客户端。
+4. 严禁前端/看板直连外部写;回写一律走 mdp_outbox + MdpApiPushExecutor。
+5. 每步输出:目标/输入/拟改文件/完成标准/验收 SQL/是否需确认。
+6. 完成后跑验收 SQL、补 doc/plan/数据库迁移/Sx/ 验收文档、按实际提交端升版本。
+```
+
+## 附录 B:S5 样板(首个落地,供 S6/S7 照抄)
+
+**目标**:S5 采购收货以"方式甲(WMS DB)"+"方式乙(WMS API)"两条路都能落 `mdp_std_purchase_receipt`,IQC 判定结果回写 QMS。
+
+1. **登记源**(二选一或都登记):
+   - 甲:`INSERT mdp_source(source_code='WMS_MAIN', source_type='DB', db_type='SQLSERVER', db_host/db_port/db_name/db_user/db_password_enc=...)`。
+   - 乙:`INSERT mdp_source(source_code='WMS_API', source_type='API', api_base_url='https://wms/api', api_auth_type='BEARER', api_auth_config='{...}')`。
+2. **登记实体**:
+   - 甲:`mdp_entity(source_id=WMS_MAIN, entity_code='purchase_receipt', source_table_name='<WMS收货表>', incr_column='update_time', target_table_name='mdp_stg_purchase_receipt', sync_mode='INCREMENTAL', batch_size=1000)`。
+   - 乙:`mdp_entity(source_id=WMS_API, entity_code='purchase_receipt', source_api_path='/receipt', response_data_path='data.list', dedup_key_path='receiptNo', last_cursor=NULL, target_table_name='mdp_stg_purchase_receipt')`。
+3. **建表**:`mdp_stg_purchase_receipt`(若无);`mdp_std_purchase_receipt` 已存在则复用。
+4. **入站**:跑 P-D 分发器 → stg → 转换到 `mdp_std_purchase_receipt`(`PurchaseReceiptMdpSyncService` 已有,改为读 stg 而非本库快照)。
+5. **业务列表**:`MaterialWarehouse/PurchaseReceiptService.cs` 已读 `mdp_std_purchase_receipt`,验证无需改。
+6. **回写**:IQC 判合格 → `INSERT mdp_outbox(target_source_code='QMS_API', action_code='S5_IQC_RESULT_PUSH', idem_key=<检验单号>, payload_json=...)` → `MdpOutboxPushJob` 推送。
+7. **验收**:§6 全套 SQL;确认 stg→std 行数、`PurchaseReceiptService` 列表出数、outbox 推送成功。
+
+---
+
+## 附录 C:本任务书建议登记待办(并入 `Ai-DOP项目待办清单.md`)
+
+| 编号 | 对应 | 说明 |
+|------|------|------|
+| P-012 | FIX-1 | S5–S7 KPI 租户参数化(P0 交付阻断) |
+| P-013 | P-A~P-F | 多源执行器 + 配置中心真实化 |
+| P-014 | S1–S7 入站 | 业务明细持续同步逐模块落地 |
+| P-015 | 增量窗口 | 衔接 P-011 增量可控 |
+| P-016 | 多租户 | qms_* 隔离(见完整方案 WP7) |
+| P-017 | P-E + 回写 | 回写 Outbox + 各模块 API_PUSH |
+
+## 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-07-22 | 初版:执行者须知 + 平台任务卡 P-A~P-F + 模块任务卡 S0–S9(甲/乙/回写)+ DDL/验收模板 + FIX-1 缺陷卡 + 执行顺序 + S5 样板附录 |

+ 230 - 0
doc/plan/AIDOP双模式架构与第三方对接现状基线.md

@@ -0,0 +1,230 @@
+# AIDOP 双模式架构与第三方对接现状基线
+
+| 项 | 内容 |
+|----|------|
+| 文档定位 | 顶层架构口径基线:**模块独立作业 + 数据中台对接第三方** 双模式;各模块对接第三方的真实程度与缺口 |
+| 编写日期 | 2026-07-19 |
+| 适用读者 | 产品负责人、架构负责人、后端/前端开发、大语言模型执行代理、测试负责人 |
+| 相关方案 | [`数据中台同步配置中心总体方案.md`](./数据中台同步配置中心总体方案.md)、[`Ai-DOP数据中台建设规划方案.md`](./Ai-DOP数据中台建设规划方案.md)、[`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) |
+| 关联待办 | P-001(T-MDP 同步配置中心)、P-011(T-CAP 增量可控) |
+
+---
+
+## 1. 顶层设计:双模式并行
+
+AIDOP 的顶层设计是 **两种运行模式并行**:
+
+1. **独立作业模式**:各业务模块拥有自己的业务表与流程(订单评审、IQC 来料检验、收发货、排程等),**不依赖外部系统也能完整运行**。
+2. **数据中台对接模式**:由 MDP(数据中台)层负责从第三方系统抽数、贴源(staging)、标准化(standard)、明细(DWD)、指标(KPI),供看板与运营指标消费。
+
+> 该双模式设计本身成立且有方案支撑;本文件的目的是**如实记录"对接到什么程度"**,避免把"已规划"误读为"已落地"。
+
+---
+
+## 2. 关键校准:当前"对接"深度很浅
+
+**必须区分「KPI 指标对接」与「业务明细数据对接」——目前只做了前者,且仅 T8。**
+
+- S5/S6/S7 的 `*MdpSyncTransformService` 通过多库 `ConfigId=t8_v5` 只读查 T8 ERP(SQL Server),**跳过贴源层**,直接产出 `dwd_t8_*` + `ado_s9_kpi_value_l1_day`,**只供看板 KPI**。
+- 它**不回灌业务表**。S5 的 IQC 单、收货/入库/退料等**业务列表数据仍来自本库**(历史一次性抽数 + UAT 造数的静态快照),并未从 T8/WMS/QMS 持续同步。
+
+一句话口径:
+
+> **全系统目前只有"KPI 指标"这一类数据实现了运行时第三方对接(且仅 T8);所有模块的"业务明细数据"都还没有持续的第三方同步,靠的是历史快照。**
+
+---
+
+## 3. 各模块对接第三方现状矩阵
+
+| 模块 | 独立作业 | 第三方对接现状 | 说明 |
+|------|:---:|------|------|
+| S1–S4 | ✅ | ❌ 无运行时第三方连接 | MDP 为**本库贴源**(源表已在 `aidopdev`);数据靠历史一次性抽数(`dopdemorq` → `aidopdev`)+ UAT 造数,不实时连外部 |
+| S5–S7 | ✅ | 🔶 **仅 KPI 对接 T8** | 运行时跨库只读查 T8(`t8_v5`)→ 只产 KPI(跳过 stg);**业务明细表仍是本库静态数据** |
+| S8 | ✅ | 🔶 独立动态数据源(监控用) | `S8SqlSugarScopeFactory` 按监控 endpoint 动态建连;与 MDP 是**两套独立机制** |
+| S9 | — | 汇总层 | 消费上游 KPI,无独立对接 |
+
+**图例**:✅ 已具备 · 🔶 部分/单点 · ❌ 未实现
+
+### 3.1 运行时第三方连接一览
+
+| 连接 | ConfigId | 类型 | 用途 | 边界 |
+|------|----------|------|------|------|
+| 主库 `aidopdev` | 默认(无 ConfigId) | MySQL | 全部业务 + MDP 贴源 | 读写 |
+| T8 ERP `t8_V5_09_56` | `t8_v5` | SqlServer | S5/S6/S7 KPI 取数 | **只读**;`EnableInit*=false`;只在 refresh 作业里调;禁看板实时查 |
+| S8 监控源 | `s8-eval-{hash}`(动态) | 按规则配置 | S8 监控规则动态取数 | 独立于 MDP |
+
+> 历史源 `dopdemorq`(SQL Server `123.60.180.165`):**仅历史一次性抽数用**,运行时 MDP 不连。
+
+---
+
+## 4. 要让双模式真正落地,缺的三块能力
+
+1. **业务数据的持续同步链路(不只是 KPI)**
+   源系统 → `mdp_stg_*`(贴源)→ `mdp_std_*`(标准层)→ 业务读标准层,带**增量字段 + 同步窗口**。目前只有 KPI 走了 T8,业务明细这条链路是断的。
+2. **多源系统接入**
+   现在只硬编码了一个 T8(`t8_v5`)。MES / WMS / QMS / SRM 等尚无各自的 `mdp_source` + 连接配置 + 抽数执行器。
+3. **API / 消息类对接执行器**
+   方案里规划了 `API_PULL` / `API_PUSH` / `API_OUT`,但**代码里尚无实现**——当前仅 DB 直连(T8)跑通。
+
+---
+
+## 5. 与既有待办的关系
+
+- 本基线所述"业务数据持续同步 + 多源 + 多类型对接"能力,本质是 **P-001 / 专题 T-MDP(数据中台同步配置中心)** 从"KPI 单点"扩成通用对接能力的目标。
+- 增量同步应遵循 **P-011 / 专题 T-CAP(增量可控)** 的稳态有界原则(增量字段 + 滚动窗口,避免全量重刷)。
+- 具体源系统对接前需先决策:**源系统连接方式(DB 直连 / API)** 与 **记录系统归属(避免与业务回写双写冲突,如 IQC 回写 `qms_qcp_inspbill`)**。
+
+---
+
+## 6. 模块×第三方对接明细清单(现状 + 目标态)
+
+> 本节把第 3 节的矩阵下钻到**业务子功能**,逐条记录数据来源、对接方式、产物、租户列与目标态蓝图。
+> 对接方式图例:**本库快照**(历史抽数/CRUD,无持续外部同步)· **本库 MDP 贴源**(stg→std→dwd→KPI,源在本库)· **T8 实时**(`t8_v5` 只读)· **S8 动态源**(`s8-eval-*`)· **外部占位**(写本库跟踪表,真实 API 未接)· **无**。
+
+### 6.0 对接方式总分类
+
+| 对接方式 | 机制 / ConfigId | 适用 | 说明 |
+|---------|----------------|------|------|
+| 本库快照 | 默认 MySQL `aidopdev` | S0–S7 业务明细 | 历史一次性抽数 + 本库流程写入 |
+| 本库 MDP 贴源 | `mdp_source=AIDOPDEV_MYSQL` | S1–S4 KPI/看板;S5/S6 部分业务标准层 | stg→std→dwd→`ado_s9_kpi_*` |
+| T8 实时只读 | `t8_v5`(SQL Server) | S5/S6/S7 KPI | **跳过 stg/std**,直查 T8→`dwd_t8_*`→`ado_s9_kpi_value_l1_day` |
+| S8 动态源 | `s8-eval-{hash}` / `s8-watch-sql-{guid}` | S8 监视规则 | 读 `ado_s8_data_source.Endpoint`,与 MDP 独立 |
+| 外部占位 | 写本库 `QadTracking` | S3 采购申请外推 | SAP/QAD 真实 API **未接入** |
+
+**T8 源表(S5/S6/S7 共用)**:`kc_tz_head/list`、`kc_dd_head/list/list_cllist`、`Cj_Bg_Head_Rep`、`sys_pelist`、`kc_zj_list`。
+
+### 6.1 S0 运营建模(主数据)— 无第三方,本库快照
+
+| 子功能域 | 源表 | 对接 | 租户列 |
+|---------|------|------|--------|
+| 制造/销售/供应/仓储主数据 | `ProductStructureMaster`、`ItemMaster`、`CustMaster`、`SuppMaster`、`LocationMaster` 等 | 本库快照 | 有 |
+| 质量主数据 | **`qms_*` 全系列** | 本库快照(QMS 规范) | **全部无 tenant** |
+| 指标/建模元数据 | `ado_smart_ops_kpi_master`、`ado_smart_ops_business_fact` | 本库 | 有 |
+
+S0 **无** MDP Sync/Refresh。
+
+### 6.2 S1 产销协同
+
+| 子功能 | 源表 | 对接 | 产物 | 租户 |
+|--------|------|------|------|------|
+| 产品设计/合同评审/订单评审 | `ado_product_design*`、`ado_contract_review*`、`crm_seorder*` | 本库快照→S1 MDP | KPI+DWD | 有 |
+| 订单交付/需求核验 | `dwd_ship_trans`、`mdp_std_so`、`dwd_requirement_examine_detail` | 本库 MDP | DWD 宽表 | 有 |
+| 订单发货/工单下达/计划联动 | `ASNBOLShipper*`、`WorkOrdMaster`、`NbrMaster(SM)`、`LinkagePlan` | 本库读写 | 业务+std | 有 |
+| 看板/KPI | `ado_s9_kpi_value_*` | 本库 MDP | KPI L1/L2 | 有 |
+
+管线:`S1MdpSyncTransformService` + `S1MdpSyncTransformJob`(15 源表→`mdp_std_so`/`mdp_std_ship_trans`→`dwd_*`→KPI)。**第三方:无。**
+
+### 6.3 S2 制造协同(排程)
+
+| 子功能 | 源表 | 对接 | 产物 |
+|--------|------|------|------|
+| 生产排程/异常/日计划/日历 | `WorkOrdMaster`、`PeriodSequenceDet`、`ScheduleResultOpMaster` 等 | 本库读写 | 业务 |
+| 看板/KPI | `dwd_order_schedule_trans`、`ado_s9_kpi_*` | 本库 MDP | KPI+原子层 |
+
+管线:`S2MdpSyncTransformService` + `S2MdpSyncTransformJob`。**第三方:无。**
+
+### 6.4 S3 供应协同(采购计划)
+
+| 子功能 | 源表 | 对接 | 产物 |
+|--------|------|------|------|
+| 需求/交货计划、采购申请/要货令/PO/委外 | `ic_demandschedule`、`srm_pr_main`、`PurOrd*`、`srm_purchase` | 本库读写→S3 MDP | 业务+KPI |
+| 供应看板/齐套 | `dwd_material_readiness`、`dwd_supplier_delivery` | 本库 MDP | DWD/KPI |
+| **采购申请外推** | 写 **`QadTracking`** | **外部占位(SAP/QAD 未接)** | 跟踪记录 |
+
+管线:`S3MdpSyncTransformService`(19 源表)+ Job。**真实第三方:无(QadTracking 为占位)。**
+
+### 6.5 S4 采购执行(收发货/退货)
+
+| 子功能 | 源表 | 对接 | 租户 |
+|--------|------|------|------|
+| 供应商交货/发货单/采购退货 | `srm_polist_ds`、`scm_shd/zb`、`dwd_supplier_delivery` | 本库+MDP DWD | 有 |
+| IQC 退货查询 | **`qms_qcp_insappnentry/inspecapplyn/inspbill`** | 本库 QMS 快照 | **无 tenant** |
+| 执行看板/欠料 | `dwd_s4_purchase_execution`、`dwd_material_shortage` | 本库 MDP | KPI L1~L3 |
+
+管线:`S4MdpSyncTransformService` + Job(共享 S3 采购链)。**第三方:无。**
+
+### 6.6 S5 来料仓储 / IQC — 🔶 T8 KPI + 本库业务
+
+| 子功能 | 源表 | 对接 | 产物 | 租户 |
+|--------|------|------|------|------|
+| 来料检验申请/任务/结果/流程 | **`qms_qcp_inspecapplyn`**、**`qms_qcp_inspbill`** | 本库写 QMS | 业务 | **无 tenant** |
+| 委外发料 | `mdp_std_outsource_issue*` ← `NbrMaster(CA)` | 本库 MDP | 业务明细 | std 有 |
+| 采购收货 | `mdp_std_purchase_receipt` ← `PurOrdRct*(rc)` | 本库 MDP(跳过 stg) | 业务明细 | std 有 |
+| 生产入库/退料 | `mdp_std_production_receipt/return*` ← `Nbr*(WOI/WOD)` | 本库 MDP | 业务明细 | std 有 |
+| 库存/标签/盘点 | `MissedPrint`、`LocationMaster/Detail`、`InvTransHist` | 本库 WMS | — | `MissedPrint` 无 tenant |
+| **KPI 看板** | `dwd_t8_material_*`→`ado_s9_kpi_*` | **T8 实时 `t8_v5`** | **仅 KPI** | KPI 有 |
+
+Sync/Refresh:`S5MdpSyncTransformService`+`S5MdpRefreshJob`(**仅 KPI,跳 stg**);`OutsourceIssue/PurchaseReceipt/ProductionReturn/ProductionReceipt MdpSyncService`(业务 std)。
+
+### 6.7 S6 制造执行 / 过程检验 — 🔶 T8 KPI
+
+| 子功能 | 源表 | 对接 | 产物 |
+|--------|------|------|------|
+| 生产指令单列表 | —(菜单占位,无后端) | — | — |
+| 过程检验单 | `mdp_std_ipqc_inspection` ← **`qms_gcjyd`**(源空表待上游) | 本库 MDP(有 stg) | 业务 |
+| 设备/模工具台账 | `EquipmentList`、`MoldToolList` | 本库快照 | 业务 |
+| **KPI 看板** | `dwd_t8_work_order_mfg_*`→`ado_s9_kpi_*` | **T8 实时** | **仅 KPI** |
+
+Sync/Refresh:`S6MdpSyncTransformService`+`S6MdpRefreshJob`(仅 KPI);`IpqcInspectionMdpSyncService`(业务)。
+
+### 6.8 S7 成品仓储 — 🔶 T8 KPI
+
+| 子功能 | 源表 | 对接 | 产物 |
+|--------|------|------|------|
+| FQC 任务/结果 | 预期 `qms_qcpp_inspbill`(**后端未接**,仅前端骨架) | 未实现 | — |
+| 生产入库单列表 | `NbrMaster⋈NbrDetail(WOI)` | 本库直读(不按 tenant 过滤) | 业务 |
+| 销售发货通知 | `ASNBOLShipperMaster`+`DepartmentMaster` | 本库 | 业务 |
+| **KPI 看板** | `dwd_t8_order_shipment_*`、`dwd_t8_finished_warehouse_efficiency`→`ado_s9_kpi_*` | **T8 实时** | **仅 KPI** |
+
+Sync/Refresh:`S7MdpSyncTransformService`+`S7MdpRefreshJob`(仅 KPI)。S1 订单交付联查 FQC 读 `qms_qcpp_inspbill`。
+
+### 6.9 S8 异常监控 — 🔶 独立动态多源
+
+| 子功能 | 数据 | 对接 |
+|--------|------|------|
+| 监控看板/大屏/订单链路 | `ado_s8_exception`、`ado_s8_order_flow_*` | 本库聚合 |
+| 异常列表/提报/任务流 | `ado_s8_exception`、流程表 | 本库读写 |
+| **配置中心(监视规则)** | **`ado_s8_data_source.Endpoint`(可配 MySQL/SQL Server 等多源)** | **动态源 `s8-eval-*` / `s8-watch-sql-*`** |
+
+机制:`S8SqlSugarScopeFactory` 按 `ado_s8_data_source` 动态建连执行规则 SQL;命中写 `ado_s8_exception`。**与 MDP 完全独立,不读 stg/不写 KPI。**
+
+### 6.10 S9 运营指标 / 数据中台 — 汇总层
+
+| 子功能 | 数据 | 对接 |
+|--------|------|------|
+| S9 看板/九宫格/智慧诊断 | `ado_s9_kpi_value_l1~l4_day` | 消费 S1–S7 KPI |
+| KPI 原子聚合 | `SmartOpsKpiAtomicBuildService`→`ado_s9_kpi_atomic_*` | 本库(S1–S4 管线内调用) |
+| 数据中台工作台 | `mdp_source`、`mdp_entity`、`mdp_sync_*`、`mdp_transform_run_log` | 本库配置/日志 |
+| ERP 同步/日志/事务(S9-001~003) | —(前端占位) | **规划未实现** |
+
+Jobs:`SmartOpsKpiMdpBootstrapJob`(启动跑 S1–S4 全量)、`SmartOpsKpiAtomicWarmupJob`。**第三方:无独立连接。**
+
+### 6.11 目标态对接蓝图(供 P-001 排期)
+
+| 模块 | 目标源系统 | 目标对接方式 | 目标深度 | 与现状差距 |
+|------|-----------|-------------|---------|-----------|
+| S1–S2 | ERP/MES(销售/工单) | DB 直连或 API 拉,增量+窗口 | 业务明细持续同步 | 现全靠历史快照 |
+| S3–S4 | SRM/SAP/QAD | **API 拉/推**(含 `QadTracking` 落真实回执) | 业务明细+回写 | 现占位,API 执行器未实现 |
+| S5 | WMS/T8/QMS | DB 直连(收发货)+ QMS 同步(IQC) | 业务明细(现只 KPI) | 业务链断,IQC 无租户列需定隔离 |
+| S6 | MES/QMS | MES 工单执行 + QMS IPQC | 业务明细(现只 KPI) | `qms_gcjyd` 源空 |
+| S7 | WMS/T8 | DB 直连(入库/发货)+ FQC 接口 | 业务明细 + FQC 后端 | FQC 后端缺失 |
+| S8 | 多源(已支持配置) | 保持动态源 | 监控 | 已达标,扩规则类型 |
+| S9 | — | 汇总 | KPI 消费 | ERP 同步页占位待实现 |
+
+**共性缺口(与第 4 节呼应)**:① 业务数据持续同步链路(非只 KPI);② 多源接入(MES/WMS/QMS/SRM 尚无 `mdp_source`+连接);③ `API_PULL/PUSH/OUT` 执行器未实现(现仅 T8 DB 直连)。
+
+### 6.12 多租户隔离风险表(无 tenant_id)
+
+| 类别 | 表 | 影响 |
+|------|----|------|
+| 质量域 | **全部 `qms_*`(28 张)** | IQC/IPQC/FQC 数据全库共享,多租户 UAT/交付需定隔离口径(机构/工厂/单号前缀) |
+| 仓储 | `MissedPrint`(无列)、`NbrMaster`(有列但 S7 不过滤)、`LocationDetail`(按 Domain 过滤) | 标签/入库列表跨租户可见风险 |
+
+---
+
+## 7. 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-07-19 | 初版:确立"双模式并行"顶层口径;记录各模块第三方对接现状矩阵、运行时连接一览与三块能力缺口 |
+| 2026-07-22 | 新增第 6 节「模块×第三方对接明细清单」:S0–S9 逐子功能记录源表/对接方式/产物/租户列,补目标态对接蓝图(6.11)与多租户隔离风险表(6.12) |

+ 379 - 0
doc/plan/AIDOP双模式落地完整实施方案.md

@@ -0,0 +1,379 @@
+# AIDOP 双模式落地完整实施方案
+
+| 项 | 内容 |
+|----|------|
+| 文档定位 | **正式修改方案**:把"双模式并行"从当前"仅 KPI 单点对接 T8"推进到**顶层设计目标全达成**(业务数据持续同步 + 多源 + 多对接方式 + 可配置 + 可治理 + 多租户) |
+| 编写日期 | 2026-07-22 |
+| 上游依据 | [`AIDOP双模式架构与第三方对接现状基线.md`](./AIDOP双模式架构与第三方对接现状基线.md)(现状)、[`数据中台同步配置中心总体方案.md`](./数据中台同步配置中心总体方案.md)(配置中心设计)、[`数据中台方案复盘与未实现清单.md`](./数据中台方案复盘与未实现清单.md)、[`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) |
+| 关联待办 | P-001(T-MDP 同步配置中心)、P-011(T-CAP 增量可控);本方案建议新增 P-012~P-017 |
+| 状态 | **待评审**(按 `collaboration-scope`,评审确认后再进入编码/落库) |
+
+---
+
+## 1. 顶层设计目标(验收口径)
+
+"双模式真正落地"= 同时满足:
+
+1. **独立作业模式**:各模块无外部系统也能完整运行(现已具备,保持)。
+2. **数据中台对接模式**(本方案要补齐):
+   - (A) **业务明细数据**能从第三方**持续同步**(不只是 KPI):源 → `mdp_stg_*` → `mdp_std_*` → 业务读标准层,带**增量字段 + 同步窗口**。
+   - (B) **多源接入**:MES / WMS / QMS / SRM / ERP 各有独立 `mdp_source` + 连接 + 抽数执行器(不再只硬编码 T8)。
+   - (C) **多对接方式**:`DB_SYNC` / `API_PULL` / `API_PUSH` / `API_OUT` 执行器均可用(现仅 DB 直连 T8)。
+   - (D) **可配置**:接入/任务/字段/参数/调度由配置中心维护(现主要写死在 Service)。
+   - (E) **可治理**:增量可控(稳态有界)、多租户隔离、可观测/对账。
+
+> 达成标准:任意新第三方源,能**通过配置中心接入 + 少量 Service 适配**跑通"业务明细 + KPI"两类数据,且租户隔离、增量窗口、运行对账齐全。
+
+---
+
+## 2. 现状 → 目标差距(一屏总览)
+
+| 能力 | 现状 | 目标 | 差距 |
+|------|------|------|------|
+| KPI 对接 | ✅ T8(S5/S6/S7,跳 stg) | 多源、可配置 | 源写死、租户写死 |
+| 业务明细同步 | ❌ 全本库快照 | 持续同步(源→stg→std) | 链路断 |
+| 多源 | ❌ 仅 T8 | MES/WMS/QMS/SRM/ERP | 无源登记与连接 |
+| 对接方式 | 仅 DB 直连 | DB/API 拉/推/出 | API 执行器未实现 |
+| 配置化 | 🔶 页面偏静态 | 配置中心为主入口 | 5 张配置表未落、Service 未读配置 |
+| 增量控制 | ❌ 多为全量重刷 | 增量+滚动窗口 | 见 P-011 |
+| 多租户 | 🔶 部分表无 tenant | 全隔离 | qms_* 无租户列 |
+| 补缺功能 | S7 FQC 后端缺、S6 源空、S3 外推占位、S9 ERP 页占位 | 补齐 | — |
+
+---
+
+## 3. 工作包(WP)拆解
+
+> 每个 WP 标注:范围 / 主要改动 / 依赖 / 风险 / 是否交付阻断。
+
+### WP0 前置决策(不写代码,必须先定)
+
+| 决策 | 说明 | 影响 |
+|------|------|------|
+| D1 记录系统归属 | 每模块业务表的"真源"是谁(如 IQC 归 QMS 还是本库回写);避免双写冲突 | 决定 A 方向哪些表由中台写、哪些本库写 |
+| D2 连接方式 | 每源 DB 直连 or API(MES/WMS/QMS/SRM 各自) | 决定 WP3/WP4 工作量 |
+| D3 T8 账套映射 | 各 DOP 租户 ↔ T8 `ztid` 映射关系(无账套的租户 S5–S7 走造数) | 决定 WP1 与租户级 KPI |
+| D4 qms 隔离口径 | `qms_*` 无租户列,按 机构/工厂/单号前缀 还是 加列 隔离 | 决定 WP7 方案 |
+
+**产物**:一张《源系统—记录系统—连接方式—租户映射》决策表(附录 A 模板)。
+
+### WP1 缺陷修复:S5/S6/S7 KPI 租户参数化 ⛔交付阻断
+
+- **范围**:`S5/S6/S7MdpSyncTransformService` 中写死的 `1300000000001`(7/5/6 处)+ 对应 `*MdpRefreshJob` + 看板 refresh 入口。
+- **改动**:刷新入参加 `TargetTenantId`/`TargetFactoryId`(默认保持旧值不破坏 Demo);KPI 值 INSERT、`mdp_transform_run_log`、最大 Id 查询全部参数化;结合 D3 支持按租户遍历刷新。
+- **风险**:低(默认值兜底);跨模块仅 S5–S7。
+- **升版本**:后端。
+
+### WP2 业务明细持续同步链路(核心能力 A)
+
+- **范围**:把"业务明细"纳入 MDP 同步(现只 KPI)。优先级按依赖:S5 收发货/IQC → S6 IPQC → S7 入库/FQC → S3/S4 采购单据 → S1/S2。
+- **改动**:
+  1. 为每类业务明细定义 `mdp_stg_*` / `mdp_std_*`(部分已存在:`mdp_std_purchase_receipt` 等),**补齐"源 → stg"这一段**(现多数直接从本库快照读 std,源同步是断的)。
+  2. 业务列表 Service 统一读 `mdp_std_*`(已部分做到)。
+  3. 每条链路带 `sync_batch_id`、`source_row_id`、增量字段(配合 WP6)。
+- **风险**:中,涉及各业务列表读数口径;需保证"独立作业模式"下无源时仍可本库写入(双写策略见 D1)。
+- **升版本**:后端(可能少量前端)。
+
+### WP3 多源接入(核心能力 B)
+
+- **范围**:把"只有 T8"扩为可注册多源。
+- **改动**:
+  1. `mdp_source` 登记 MES/WMS/QMS/SRM/ERP(含 `source_type`、连接引用)。
+  2. **DB 直连源**:在 SqlSugar 多库配置(`Database.json`)加 `ConfigId`,仿 `t8_v5`(只读、`EnableInit*=false`)。
+  3. 把 S5–S7 里硬编码的 `ConfigId=t8_v5` 改为**从 `mdp_source` 解析**,Service 不再写死源。
+- **风险**:中;连接安全(只读账号、连接串加密)。
+- **升版本**:后端 + 配置。
+
+### WP4 API/消息对接执行器(核心能力 C)
+
+- **范围**:实现配置中心方案 §7 的 `API_PULL` / `API_PUSH` / `API_OUT`(现仅 DB 直连跑通)。
+- **改动**:
+  1. `API_PULL` 执行器:地址/方法/认证/分页/JSONPath/字段映射/重试超时 → 写 `mdp_stg_*`。
+  2. `API_PUSH`(入站 Webhook):接收端点 + 幂等键 + 映射 → stg。
+  3. `API_OUT`(出站):受控只读 API,供外部/BI 读 DWD/KPI。
+  4. 把 S3 `PurchaseRequestExternalPushService`(现写 `QadTracking` 占位)接到 `API_PUSH`/`API_OUT` 真实链路(依 D2)。
+- **风险**:高(外部副作用、认证、幂等、止损);须任务流+审计+重试。
+- **升版本**:后端。
+
+### WP5 配置中心真实化(核心能力 D)
+
+- **范围**:落地配置中心方案 §5/§11 的 Phase 1–5。
+- **改动**:
+  1. 新增 5 张配置表:`mdp_sync_task`/`_step`/`_param`/`_formula`/`_schedule`。
+  2. 种子化 S1–S7 现有任务为配置记录;数据任务页读真实任务、展示调度/最近运行/状态。
+  3. Service 执行前加载**配置快照**(参数:`sync_window_days`、`allow_empty_source` 等),运行日志记配置版本。
+  4. 复用现有 `FormulaParser/Validator/PreviewBuilder/FormulaEditor.vue`。
+- **风险**:中;遵循"第一阶段不改写复杂 SQL"原则。
+- **升版本**:后端 + 前端。
+
+### WP6 增量可控治理(能力 E-1,衔接 P-011)
+
+- **范围**:把全量重刷改为**增量 + 滚动窗口**,日志分层留存。
+- **改动**:同步任务带 `sync_window_type=ROLLING_WINDOW` + 增量水位(`last_watermark`);大 DWD 表滚动窗口重建;日志表按保留期清理(P0/P1/P2 见容量分析文档)。
+- **风险**:中;需保证增量与全量结果一致性对账。
+- **升版本**:后端。
+
+### WP7 多租户隔离补齐(能力 E-2)
+
+- **范围**:`qms_*`(28 张无租户列)+ `MissedPrint` + S7 `NbrMaster`/`LocationDetail` 过滤。
+- **改动**(依 D4):择一——
+  - 方案甲:`qms_*` 加 `tenant_id` 列 + 回填 + Service 过滤(改动大、最彻底);
+  - 方案乙:按 机构/工厂/单号前缀 逻辑隔离(改动小、需口径严谨)。
+- **风险**:高(历史数据回填、跨租户可见性、交付合规)。
+- **升版本**:后端(可能 DDL/迁移脚本)。
+
+### WP8 补缺功能
+
+| 项 | 内容 | 升版本 |
+|----|------|--------|
+| S7 FQC 后端 | 现仅前端骨架,补 Service + 源(`qms_qcpp_inspbill`) | 后端+前端 |
+| S6 IPQC 源同步 | `qms_gcjyd` 源空表,接上游同步(依 WP3/WP4) | 后端 |
+| S3 外推真实化 | `QadTracking` → 真实 SAP/QAD(依 WP4/D2) | 后端 |
+| S9 ERP 同步页 | S9-001~003 前端占位 → 配置中心真实页(依 WP5) | 前端 |
+
+---
+
+## 4. 里程碑与建议顺序
+
+| 里程碑 | 包含 WP | 目标 | 阻断关系 |
+|--------|---------|------|----------|
+| **M0 决策** | WP0 | 出决策表(D1–D4) | 阻断后续所有 |
+| **M1 交付就绪** | WP1 | S5–S7 KPI 多租户可用(交付阻断项清零) | 依 D3 |
+| **M2 平台底座** | WP3 + WP5 | 多源可登记 + 配置中心真实化 | 依 M0 |
+| **M3 双向数据流** | WP2 + WP4 + WP6 | 业务明细持续同步 + API 执行器 + 增量可控 | 依 M2 |
+| **M4 治理与补缺** | WP7 + WP8 | 多租户隔离 + 功能补齐 | 依 M3 |
+
+> 建议:**M0 → M1 先做**(M1 是交付阻断,且不依赖大平台改造);M2–M4 属平台演进,可随 P-001 排期分批。
+
+---
+
+## 5. 跨模块影响
+
+- **WP1/WP3** 动 S5–S7 共用的 T8 取数与看板 refresh 入口 → 影响三模块 KPI,需回归 Demo 租户结果不变。
+- **WP2/WP6** 动各业务列表读数口径(`mdp_std_*`)→ 影响 S1–S7 列表页与看板,需保证独立作业模式无源时仍可用。
+- **WP5** 动数据中台工作台页面与 Job 绑定 → 影响 S9 数据中台页与所有模块 Job 展示。
+- **WP7** 动 `qms_*` 与仓储表隔离 → 影响 S4/S5/S6/S7 质量与仓储列表跨租户可见性。
+
+**原则**:每个 WP 落地前单独出"改动清单 + 跨模块影响 + 回归项",按 `collaboration-scope` 确认后再改。
+
+---
+
+## 6. 非目标(本方案不做 / 后续单独立项)
+
+- 不重构 Admin.NET 调度引擎(复用)。
+- 不把配置页做成任意 SQL 执行器。
+- 不一次性改写 S1–S4 复杂 Service SQL(遵循配置中心"混合执行"原则)。
+- 不在未定 D1/D4 前动 `qms_*` DDL 或业务回写双写。
+- 不做闭环运营(异常→任务流→动作→回写验证)——属复盘文档 §5.4 后续专题。
+
+---
+
+## 7. 建议登记的待办项(供并入 `Ai-DOP项目待办清单.md`)
+
+| 编号 | 主题 | WP | 优先级 | 阻断 |
+|------|------|----|--------|------|
+| P-012 | S5–S7 KPI 租户参数化(缺陷) | WP1 | P0 | 交付阻断 |
+| P-013 | 多源接入 + 配置中心真实化 | WP3+WP5 | P1 | — |
+| P-014 | 业务明细持续同步 + API 执行器 | WP2+WP4 | P1 | 依 P-013 |
+| P-015 | 增量可控治理落地 | WP6 | P1 | 衔接 P-011 |
+| P-016 | 多租户隔离补齐(qms_* 等) | WP7 | P1 | 依 D4 |
+| P-017 | 功能补缺(S7 FQC/S6 源/S3 外推/S9 页) | WP8 | P2 | 依 P-013/014 |
+
+---
+
+## 8. 附录 A:源系统—记录系统—连接—租户 决策表模板(WP0 产物)
+
+| 模块 | 业务对象 | 记录系统(真源) | 连接方式 | ConfigId/端点 | 租户映射 | 是否本库回写 |
+|------|---------|-----------------|---------|--------------|---------|-------------|
+| S5 | 采购收货 | WMS / T8 | 待定(DB/API) | | DOP↔ztid | |
+| S5 | IQC | QMS | 待定 | | | |
+| S6 | IPQC | QMS | 待定 | | | |
+| S7 | 成品入库 | WMS | 待定 | | | |
+| S7 | FQC | QMS | 待定 | | | |
+| S3 | 采购申请外推 | SAP/QAD | API_PUSH | | | |
+| … | | | | | | |
+
+---
+
+## 10. 通用对接机制(方式甲 DB 直连 / 方式乙 API 拉取 / 回写)
+
+> 重要前提:配置表**已就绪**,无需重造——`mdp_source`(含 DB 段 `db_type/db_host/db_port/db_name/db_user/db_password_enc/db_extra_params` + API 段 `api_base_url/api_auth_type/api_auth_config` + 健康检查)、`mdp_entity`(含 `source_table_name`=甲、`source_api_path/api_config_id/response_data_path/dedup_key_path/last_cursor`=乙、`incr_column/sync_mode/batch_size`)、`mdp_sync_task/step/schedule`(实体见 `MdpSyncConfigEntities.cs`)。
+> **缺的是运行时执行器**:现在 S5/S6/S7 把 `ConfigId="t8_v5"` 写死在各 Service(`_db.AsTenant().GetConnectionScope("t8_v5")`),源不可配。
+
+### 10.1 方式甲:DB 直连(源库可直连)
+
+**目标形态**:连接来自 `mdp_source`,不再写死 `t8_v5`。
+
+- **动态连接工厂 `MdpSourceScopeFactory`**(新建,仿 S8 的 `S8SqlSugarScopeFactory`):
+  - 入参:`sourceCode` 或 `sourceId`。
+  - 读 `mdp_source` → 解密 `db_password_enc` → 拼连接串 → 注册/获取一个只读 SqlSugar `ConfigId`(约定 `mdp-src-{sourceCode}`,`EnableInit*=false`)。
+  - 返回 `ISqlSugarClient` 连接域。
+- **抽数器 `MdpDbPullExecutor`**(新建):按 `mdp_entity.source_table_name` + `incr_column` + `last_cursor`(水位)+ `batch_size` 分批查询源表 → 写 `mdp_stg_{entity}`(含 `raw_data/source_row_id/sync_batch_id`)→ 更新 `last_cursor`。
+- **Service 改造**:S5/S6/S7 把 `GetConnectionScope("t8_v5")` 换成 `MdpSourceScopeFactory.Get(sourceCode)`;T8 只是 `mdp_source` 里的一条(`source_code=T8_ERP`)。
+
+### 10.2 方式乙:API 拉取(只给接口)
+
+**目标形态**:`API_PULL` 执行器消费 `mdp_source` API 段 + `mdp_entity` API 字段。
+
+- **`MdpApiPullExecutor`**(新建,`HttpClientFactory`):
+  - 认证:按 `api_auth_type`(`NONE/BASIC/BEARER/APIKEY/OAUTH2`)+ `api_auth_config`(JSON) 取 token。
+  - 请求:`api_base_url` + `mdp_entity.source_api_path`;分页/游标用 `last_cursor`。
+  - 解析:`response_data_path`(JSONPath) 取数组;`dedup_key_path` 做幂等去重。
+  - 落库:映射 → `mdp_stg_{entity}`(同甲结构),更新 `last_cursor`。
+- **stg 之后完全复用**:std / dwd / kpi 与甲一致——**差异只在"源→stg"这一段**。
+
+### 10.3 统一执行器接口(甲/乙共用)
+
+```csharp
+public interface IMdpSourcePullExecutor {
+    string SupportedType { get; }          // "DB_SYNC" / "API_PULL"
+    Task<MdpPullResult> PullAsync(MdpSource source, MdpEntity entity, MdpPullContext ctx);
+}
+// 按 task_type / entity 选择 DB 或 API 执行器;产出统一写 mdp_stg_*
+```
+
+调度:`mdp_sync_task` 绑定 `service_key`(复杂逻辑)或标记为配置驱动(走执行器);`mdp_sync_task_schedule` 提供 Cron/窗口/重试;底层复用 Admin.NET Job。
+
+### 10.4 回写第三方(Outbox + API_PUSH / API_OUT)
+
+- **前置**:仅当"DOP 是动作发起方"(该对象记录系统归属 DOP 或需反映到源)才回写;否则只读。
+- **Outbox 表 `mdp_outbox`**(新建统一表,替代 S3 零散 `QadTracking`):`id/tenant_id/target_source_code/action_code/idem_key/payload_json/status(待推/成功/失败)/retry_count/response_json/create_time`。
+- **`MdpApiPushExecutor`**(新建 Job):扫 `mdp_outbox` 待推 → 按 `target_source_code` 取 `mdp_source` API 段 → 调第三方写接口 → 记回执/置状态 → 失败重试/止损/告警。
+- **`API_OUT`**:受控只读输出 API,供外部/BI 读 DWD/KPI(输出字段白名单 + 权限 + 调用日志)。
+- **高风险动作**(PR 合并、PO/DO 转单、SAP/QAD 推送、自动审批/释放):必须经**任务流 + 审批 + 白名单 Service**,禁止前端/看板直接触发(复盘文档 §5.5)。
+
+### 10.5 入站/出站范式图
+
+```mermaid
+flowchart LR
+  subgraph 入站
+    SRC[(第三方源)] -->|甲 MdpDbPullExecutor| STG
+    SRC -->|乙 MdpApiPullExecutor| STG[mdp_stg_*]
+    STG --> STD[mdp_std_*] --> LIST[业务列表 Service]
+    STD --> DWD[dwd_*] --> KPI[ado_s9_kpi_value_*]
+  end
+  subgraph 出站
+    ACT[DOP 动作] --> OB[mdp_outbox 幂等]
+    OB -->|MdpApiPushExecutor| SRC2[(第三方)]
+    SRC2 -->|回执| OB
+  end
+```
+
+---
+
+## 11. 全模块 S0–S9 对接规格(甲/乙/回写)
+
+> 每模块给出:目标源系统、对象、**方式甲(DB源表)**、**方式乙(API路径示意)**、**回写**、目标层落点。源表/系统为**目标态建议**,实际以 WP0 决策表为准。
+
+### 11.1 S0 运营建模(主数据)
+
+| 对象 | 目标源系统 | 甲:源表 | 乙:API 示意 | 回写 | 目标层 |
+|------|-----------|---------|-------------|------|--------|
+| 物料/BOM | ERP/PLM | `ItemMaster`/BOM 源 | `GET /api/item`、`/api/bom` | 一般否 | `mdp_std_item`/主数据表 |
+| 客户/供应商 | ERP/CRM/SRM | `CustMaster`/`SuppMaster` | `GET /api/customer`、`/api/supplier` | 否 | 主数据表 |
+| 人员/部门/产线/工作中心 | HR/MES | `EmployeeMaster`/`LineMaster`/`WorkCtrMaster` | `GET /api/employee` 等 | 否 | 主数据表 |
+| 质量规范 | QMS | `qms_inspectionstd` 等 | `GET /api/qms/std` | 否 | 本库贴源 |
+
+回写:主数据通常单向下行(源→DOP),一般不回写。
+
+### 11.2 S1 产销协同(订单/评审/交付)
+
+| 对象 | 源系统 | 甲:源表 | 乙:API | 回写 | 目标层 |
+|------|--------|---------|--------|------|--------|
+| 销售订单/明细 | ERP/CRM | `crm_seorder`/`crm_seorderentry` | `GET /api/salesorder` | **订单状态/评审结果回 CRM/ERP**(API_PUSH) | `mdp_std_so`→`dwd_ship_trans` |
+| 发货/ASN | ERP/WMS | `ASNBOLShipper*` | `GET /api/asn` | 发货确认回 ERP | `mdp_std_ship_trans` |
+| 交付需求核验 | 本域计算 | — | — | 否 | `dwd_requirement_examine_detail` |
+
+### 11.3 S2 制造协同(排程)
+
+| 对象 | 源系统 | 甲:源表 | 乙:API | 回写 | 目标层 |
+|------|--------|---------|--------|------|--------|
+| 工单/工序 | MES/APS | `WorkOrdMaster`/`Routing`/`Detail` | `GET /api/workorder` | **排程结果下发 MES**(API_PUSH) | `mdp_std_work_order_schedule` |
+| 排程结果/日历 | APS | `ScheduleResultOpMaster`/`PeriodSequenceDet` | `GET /api/schedule` | 计划下发 | `dwd_order_schedule_trans` |
+
+### 11.4 S3 供应协同(采购计划)
+
+| 对象 | 源系统 | 甲:源表 | 乙:API | 回写 | 目标层 |
+|------|--------|---------|--------|------|--------|
+| 供应商/物料/采购申请 | SRM/ERP | `srm_pr_main`/`SuppMaster`/`ItemMaster` | `GET /api/pr`、`/api/supplier` | **PR/PO 推 SAP/QAD**(API_PUSH,高风险,走任务流) | `mdp_std_*`→`dwd_supplier_delivery` |
+| 采购订单/交货计划 | SRM/ERP | `PurOrd*`/`srm_polist_ds` | `GET /api/po` | PO 转单 | `dwd_material_readiness` |
+
+### 11.5 S4 采购执行(收发货/退货)
+
+| 对象 | 源系统 | 甲:源表 | 乙:API | 回写 | 目标层 |
+|------|--------|---------|--------|------|--------|
+| 供应商发货/交货 | SRM/ERP | `scm_shd/zb`/`srm_polist_ds` | `GET /api/delivery` | 收货确认回 ERP(API_PUSH) | `dwd_s4_purchase_execution` |
+| 采购退货 | ERP/WMS | 退货源表 | `GET /api/return` | 退货单回写 | `dwd_qc_trans` |
+| IQC 退货 | QMS | `qms_qcp_insappnentry` 等 | `GET /api/qms/iqcreturn` | 否 | 本库 |
+
+### 11.6 S5 来料仓储 / IQC(样板,见附录 B)
+
+| 对象 | 源系统 | 甲:源表 | 乙:API | 回写 | 目标层 |
+|------|--------|---------|--------|------|--------|
+| 采购收货 | **WMS** | WMS 收货表→`PurOrdRct*` | `GET /api/wms/receipt` | 否(WMS 为真源) | `mdp_std_purchase_receipt` |
+| 生产入库/退料 | **MES/WMS** | `Nbr*(WOI/WOD)` | `GET /api/mes/inbound` | 否 | `mdp_std_production_receipt/return` |
+| 委外发料 | **WMS** | `Nbr*(CA)` | `GET /api/wms/outsource-issue` | 否 | `mdp_std_outsource_issue` |
+| IQC 来料检验 | **QMS** | `qms_qcp_inspbill` | `GET /api/qms/iqc` | **IQC 判定结果回 QMS/ERP**(API_PUSH) | 本库 QMS |
+| KPI | T8/派生 | `dwd_t8_material_*` | — | 否 | `ado_s9_kpi_value_l1_day` |
+
+### 11.7 S6 制造执行 / IPQC
+
+| 对象 | 源系统 | 甲:源表 | 乙:API | 回写 | 目标层 |
+|------|--------|---------|--------|------|--------|
+| 生产报工/工序执行 | **MES** | MES 报工表 | `GET /api/mes/report` | **报工/完工确认回 MES**(可选) | `mdp_std_*`(待建) |
+| 过程检验 IPQC | **QMS** | `qms_gcjyd`(源空,待接) | `GET /api/qms/ipqc` | IPQC 结果回 QMS | `mdp_std_ipqc_inspection` |
+| 设备/模工具台账 | EAM/MES | `EquipmentList`/`MoldToolList` | `GET /api/eam` | 否 | 本库 |
+| KPI | T8/派生 | `dwd_t8_work_order_mfg_*` | — | 否 | `ado_s9_kpi_value_l1_day` |
+
+### 11.8 S7 成品仓储 / FQC
+
+| 对象 | 源系统 | 甲:源表 | 乙:API | 回写 | 目标层 |
+|------|--------|---------|--------|------|--------|
+| 成品入库 | **WMS** | `NbrMaster(WOI)` | `GET /api/wms/fg-inbound` | 否 | 待建 std |
+| 销售发货通知 | ERP/WMS | `ASNBOLShipperMaster` | `GET /api/asn` | 发货回 ERP | 本库 |
+| FQC 检验(后端待建) | **QMS** | `qms_qcpp_inspbill` | `GET /api/qms/fqc` | FQC 结果回 QMS | 待建 |
+| KPI | T8/派生 | `dwd_t8_order_shipment_*` | — | 否 | `ado_s9_kpi_value_l1_day` |
+
+### 11.9 S8 异常监控(已支持多源,保持)
+
+| 能力 | 机制 | 甲/乙 | 说明 |
+|------|------|------|------|
+| 监视规则取数 | `S8SqlSugarScopeFactory` + `ado_s8_data_source` | **已支持 DB 动态源**;API 型规则可扩展复用 `MdpApiPullExecutor` | 命中写 `ado_s8_exception` |
+
+S8 已达"多源动态"目标,本方案仅建议:API 型监视规则复用 §10.2 执行器,避免两套 API 客户端。
+
+### 11.10 S9 运营指标 / 数据中台
+
+| 能力 | 机制 | 甲/乙 | 说明 |
+|------|------|------|------|
+| KPI 汇总 | 消费 S1–S7 `ado_s9_kpi_value_*` | 无外部 | 汇总层 |
+| 数据中台工作台 | `mdp_source/entity/sync_task*` 配置 CRUD | **甲/乙配置入口** | S9-001~003 ERP 同步页由占位 → 真实(WP5/WP8) |
+| API_OUT | 受控只读输出 | 乙(出站) | 供外部/BI 读 DWD/KPI |
+
+### 11.11 全模块回写一览
+
+| 模块 | 回写对象 | 目标系统 | 方式 | 风险 |
+|------|---------|---------|------|------|
+| S1 | 订单/评审状态 | CRM/ERP | API_PUSH | 中 |
+| S2 | 排程/计划下发 | MES/APS | API_PUSH | 中 |
+| S3 | PR/PO 推送、转单 | SAP/QAD/SRM | API_PUSH+任务流 | **高** |
+| S4 | 收货/退货确认 | ERP/WMS | API_PUSH | 中 |
+| S5 | IQC 判定结果 | QMS/ERP | API_PUSH | 中 |
+| S6 | 报工/IPQC 结果 | MES/QMS | API_PUSH | 中 |
+| S7 | 发货/FQC 结果 | ERP/WMS/QMS | API_PUSH | 中 |
+
+**统一**:所有回写走 `mdp_outbox` + `MdpApiPushExecutor`(§10.4),高风险经任务流+审批。
+
+---
+
+## 12. 配套执行任务书
+
+本方案的**可直接执行版**见:[`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md)(第三方大模型照做级:约定、DDL、执行器接口、逐模块步骤、验收 SQL)。
+
+---
+
+## 13. 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-07-22 | 初版:现状→目标差距、WP0–WP8、M0–M4、跨模块影响、非目标、待办建议(P-012~P-017)|
+| 2026-07-22 | 增补第 10 节(通用对接机制:方式甲 DB 直连 / 方式乙 API 拉取 / 回写 Outbox)、第 11 节(S0–S9 全模块甲/乙/回写规格)、第 12 节(配套执行任务书链接);核实 `mdp_source`/`mdp_entity`/`mdp_sync_task*` 已就绪,缺运行时执行器 |

+ 306 - 0
doc/plan/Ai-DOP数据中台支撑层建设项目方案.md

@@ -0,0 +1,306 @@
+# Ai-DOP 数据中台支撑层建设项目方案
+
+> 文档版本:V2.0  
+> 编制日期:2026 年 7 月
+
+---
+
+## 一、项目概述
+
+### 1.1 项目名称
+
+Ai-DOP 数据中台支撑层建设项目。
+
+### 1.2 项目定位
+
+本项目是在 Ai-DOP 系统内部建设统一的数据中台支撑层,不建设独立软件。
+
+项目沿用 Ai-DOP 现有的用户、权限、菜单、数据库、任务调度和部署环境,对企业 ERP、MES、WMS 及其他业务系统的数据进行统一管理和整理,为产销协同、制造协同、供应协同、采购执行、仓储、生产执行、异常监控和运营指标等功能提供稳定的数据基础。
+
+### 1.3 建设范围边界
+
+本项目建设各业务模块共同依赖的数据管理、数据标准化、共享数据服务、任务运行和监控追踪能力。
+
+项目不包含 Ai-DOP 各业务模块自身的页面、业务流程、业务表单和 BI 展示,也不建设独立的用户、权限、部署和运维体系。
+
+---
+
+## 二、建设背景与必要性
+
+Ai-DOP 需要同时使用订单、工单、物料、供应商、采购、收货、库存、质量和交付等多类数据。这些数据来自不同系统,字段、编码和业务口径存在差异。
+
+如果各业务模块分别接入和处理数据,将产生以下问题:
+
+1. 同一数据被多次接入和重复整理。
+2. 不同模块对相同数据形成不同口径。
+3. 外部系统变化时,需要同时修改多个业务模块。
+4. 数据异常难以快速判断发生在哪个环节。
+5. 数据任务分散,缺少统一运行和管理入口。
+
+建设数据中台支撑层,可以在外部系统与 Ai-DOP 业务模块之间形成统一的数据处理和共享机制,降低重复建设成本,提高数据一致性和系统可维护性。
+
+---
+
+## 三、建设目标
+
+### 3.1 总体目标
+
+在 Ai-DOP 内建立统一、稳定、可管理的数据支撑体系,使不同来源的数据经过统一整理后,为多个业务模块共享使用。
+
+### 3.2 具体目标
+
+1. **统一接入管理**:集中管理数据来源、数据任务和业务用途。
+2. **统一数据标准**:统一关键业务数据的字段、编码、状态和口径。
+3. **统一共享服务**:同一类业务数据一次整理,供多个模块共同使用。
+4. **统一任务运行**:支持数据任务定时运行、人工触发和状态查询。
+5. **统一监控追踪**:能够查看任务运行结果、处理数量和异常原因。
+6. **统一扩展方式**:为后续新增系统和业务模块提供可复用的接入方式。
+
+---
+
+## 四、主要建设内容
+
+### 4.1 数据来源与任务管理
+
+集中管理接入 Ai-DOP 的数据来源和数据任务,包括:
+
+- 数据来源名称、类型和用途。
+- 数据任务名称、业务范围和启用状态。
+- 数据任务服务的业务模块。
+- 数据任务与定时运行计划的关联。
+- 数据对象和关键字段对应关系。
+
+### 4.2 业务数据标准化
+
+对来自不同系统的数据进行统一整理,重点覆盖:
+
+- 销售订单和交付数据。
+- 工单、工序和排程数据。
+- 物料、供应商和采购数据。
+- 送货、收货、检验和退货数据。
+- 原材料、在制品和成品库存数据。
+- 生产执行、质量和异常数据。
+
+统一处理字段名称、业务编码、日期格式、状态定义、数量单位和数据关联关系,为各业务模块提供一致的数据口径。
+
+### 4.3 共享数据服务
+
+按订单交付、工单排程、供应采购、采购执行、物料仓储、生产执行和成品仓储等业务领域整理共享数据。
+
+共享数据主要服务于:
+
+- Ai-DOP 业务页面查询。
+- 跨模块业务协同。
+- 异常监控和问题预警。
+- 运营指标和管理看板。
+- 数据分析和后续智能应用。
+
+### 4.4 数据任务运行管理
+
+复用 Ai-DOP 现有任务调度能力,实现:
+
+- 数据任务按计划自动运行。
+- 必要情况下人工触发。
+- 查看最近运行时间和状态。
+- 查看任务处理数量和运行时长。
+- 失败任务记录错误原因。
+
+具体运行频率根据业务需要、数据量和外部系统承载能力确定。
+
+### 4.5 运行监控与问题追踪
+
+在 Ai-DOP 内提供统一的数据任务监控入口,支持:
+
+- 按业务领域、任务、状态和时间查询。
+- 查看任务运行结果和数据处理数量。
+- 查看数据从来源到业务使用的主要过程。
+- 按批次定位数据处理问题。
+- 查看数据任务与相关业务模块的关系。
+
+### 4.6 数据中台工作台
+
+建设集成在 Ai-DOP 内的数据中台工作台,集中展示:
+
+- 已接入的数据来源。
+- 主要数据任务。
+- 最近运行情况。
+- 异常任务提醒。
+- 数据流向和业务使用关系。
+- 配置、监控和任务调度入口。
+
+### 4.7 安全与权限
+
+数据中台支撑层沿用 Ai-DOP 现有权限和租户管理方式。
+
+敏感连接信息不在普通页面明文展示,业务用户通过受控功能使用数据,不直接访问数据库。
+
+---
+
+## 五、实施方案
+
+### 5.1 实施原则
+
+1. 与 Ai-DOP 统一建设、统一部署、统一运维。
+2. 先建设公共能力,再按业务领域逐步接入。
+3. 优先接入影响范围广、使用频率高的数据。
+4. 每个阶段均进行数据核对和业务确认。
+5. 控制项目范围,新增系统和大规模历史数据治理单独评估。
+
+### 5.2 实施阶段
+
+#### 第一阶段:范围确认与数据盘点
+
+- 确认数据来源和接入条件。
+- 梳理主要业务数据和使用模块。
+- 确认关键字段和业务口径。
+- 确定首批数据任务及验收样本。
+
+#### 第二阶段:数据中台基础能力建设
+
+- 建设数据来源和数据任务管理。
+- 建立统一的数据整理和共享规则。
+- 接入 Ai-DOP 任务调度。
+- 建设数据中台工作台和基础监控入口。
+
+#### 第三阶段:业务数据接入
+
+- 按业务领域接入主要数据。
+- 完成数据标准化和共享数据整理。
+- 为相关业务模块提供数据服务。
+- 完成主要任务的自动运行配置。
+
+#### 第四阶段:监控、联调与验收
+
+- 完善运行监控和问题追踪。
+- 完成业务模块联调。
+- 开展数据抽样核对。
+- 完成操作、运维和验收资料。
+
+---
+
+## 六、资源与成本估算
+
+### 6.1 项目团队
+
+建议配置以下角色:
+
+| 角色 | 主要职责 |
+|---|---|
+| 项目经理 | 计划、协调、范围和交付管理 |
+| 产品/实施顾问 | 业务梳理、口径确认和用户沟通 |
+| 数据工程师 | 数据盘点、标准化、数据整理和核对 |
+| 后端开发工程师 | 数据任务、共享服务和运行监控建设 |
+| 前端开发工程师 | 数据中台工作台和管理页面建设 |
+| 测试工程师 | 功能测试、数据核对和验收支持 |
+| 运维工程师 | 环境、部署、调度和运行保障 |
+
+### 6.2 工作量估算
+
+工作量按本项目完整建设范围测算,覆盖从需求梳理、总体设计、数据盘点、功能建设到联调、测试、部署和最终交付的全部投入,不以某一实施阶段的剩余任务量作为测算口径。
+
+| 工作内容 | 参考工作量 |
+|---|---:|
+| 需求梳理、数据盘点和总体设计 | 1.5—2 人月 |
+| 数据来源与任务管理 | 1—1.5 人月 |
+| 七类业务数据标准化和共享服务 | 3.5—5.5 人月 |
+| 数据中台工作台、任务运行和监控追踪 | 1.5—2 人月 |
+| 数据核对、联调和测试 | 1.5—2.5 人月 |
+| 项目管理、部署和交付 | 0.5—1 人月 |
+| **合计** | **9.5—14.5 人月** |
+
+### 6.3 成本影响因素
+
+项目成本主要受以下因素影响:
+
+- 接入系统和数据来源数量。
+- 外部系统接口资料的完整程度。
+- 历史数据质量和数据量。
+- 业务口径确认难度。
+- 第三方厂商配合程度。
+- 是否需要现场实施、专线或额外安全措施。
+
+以上工作量反映本项目完整建设投入,用于立项和资源测算;具体商务报价根据最终确认的数据来源、业务范围和实施条件确定。
+
+---
+
+## 七、项目计划
+
+建议项目周期为 12—16 周。
+
+| 阶段 | 参考周期 | 主要成果 |
+|---|---:|---|
+| 范围确认与数据盘点 | 2—3 周 | 数据来源、任务、口径和验收清单 |
+| 基础能力建设 | 3—4 周 | 数据任务管理、工作台、调度和基础监控 |
+| 业务数据接入 | 5—7 周 | 主要业务数据标准化和共享服务 |
+| 联调与验收 | 2—3 周 | 数据核对、测试报告、操作及运维资料 |
+
+如外部系统接口、测试数据或业务口径确认延迟,项目计划相应调整。
+
+---
+
+## 八、交付物
+
+### 8.1 系统交付
+
+1. 集成在 Ai-DOP 内的数据中台管理入口。
+2. 数据来源和数据任务管理功能。
+3. 主要业务领域的数据标准化和共享数据服务。
+4. 数据任务自动运行和人工触发能力。
+5. 数据任务运行监控和问题追踪功能。
+6. 数据中台工作台和数据流向展示。
+
+### 8.2 文档交付
+
+1. 数据中台支撑层建设方案。
+2. 数据来源和数据任务清单。
+3. 主要业务数据标准说明。
+4. 业务领域与使用模块关系清单。
+5. 部署和配置说明。
+6. 用户操作及运维手册。
+7. 测试和数据核对报告。
+8. 项目验收报告。
+
+---
+
+## 九、验收原则
+
+1. 以双方确认的数据来源、数据任务和业务范围为依据。
+2. 主要数据任务能够按计划运行或人工触发。
+3. 能够查看任务运行状态、处理数量和错误信息。
+4. 主要业务数据按照确认口径完成整理,并通过抽样核对。
+5. 主要业务模块能够正常使用数据中台提供的数据。
+6. 运行监控、问题追踪和相关文档齐全。
+7. 因外部系统不可用、源数据缺失或第三方接口变化产生的问题,应先确认责任边界。
+8. 不以所有历史数据一次性完全一致作为验收前提,差异可解释并有处理意见即可。
+
+---
+
+## 十、预期效益
+
+### 10.1 业务效益
+
+- 提高跨模块数据一致性。
+- 为订单、生产、采购、库存和交付协同提供统一数据基础。
+- 提升异常监控和运营分析的可信度。
+
+### 10.2 建设效益
+
+- 减少重复接口和重复数据整理。
+- 新增业务模块时可复用已有数据能力。
+- 外部系统变化时可以集中调整,降低改造成本。
+
+### 10.3 运维效益
+
+- 集中查看数据任务运行情况。
+- 按任务和批次定位问题。
+- 降低长期运行和后续扩展的维护成本。
+
+---
+
+## 十一、结论
+
+Ai-DOP 数据中台支撑层是 Ai-DOP 各业务模块共享数据、稳定运行和持续扩展的必要基础。
+
+本项目通过统一数据管理、数据标准化、共享数据服务、任务运行和监控追踪,解决多系统数据差异、重复建设和问题定位困难等问题。
+
+项目不建设独立软件,而是作为 Ai-DOP 内部支撑层,与现有系统统一使用、统一部署和统一运维。

BIN
doc/plan/Ai-DOP模块工时&旧系统复用统计_20260621.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用工时统计_20260625.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用工时统计_20260625_增加分摊报价工时.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用统计矩阵.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算_人工调整版.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算_人工调整版_新规则.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用统计矩阵_容量测算_人工调整版_新规则_增加报价原始工时.xlsx


BIN
doc/plan/Ai-DOP模块旧系统复用统计矩阵_简版工时.xlsx


+ 4 - 1
doc/plan/Ai-DOP项目待办清单.md

@@ -19,7 +19,7 @@
 
 | 专题 ID | 专题名称 | 子文档(明细入口) | 背景 / 方案文档 |
 |---------|----------|-------------------|-----------------|
-| **T-MDP** | 数据中台 · MDP · 同步配置 | [`待执行任务列表.md`](./待执行任务列表.md) | [`数据中台方案复盘与未实现清单.md`](./数据中台方案复盘与未实现清单.md)、[`数据中台同步配置中心实施任务书-执行版.md`](./数据中台同步配置中心实施任务书-执行版.md) |
+| **T-MDP** | 数据中台 · MDP · 同步配置 | [`待执行任务列表.md`](./待执行任务列表.md) | [`AIDOP双模式架构与第三方对接现状基线.md`](./AIDOP双模式架构与第三方对接现状基线.md)、[`数据中台方案复盘与未实现清单.md`](./数据中台方案复盘与未实现清单.md)、[`数据中台同步配置中心实施任务书-执行版.md`](./数据中台同步配置中心实施任务书-执行版.md) |
 | **T-MONGO** | 旧系统 MongoDB 替代 | [`旧系统MongoDB替代-待办清单.md`](./旧系统MongoDB替代-待办清单.md) | [`旧系统MongoDB情况说明与新系统替代分析.md`](./旧系统MongoDB情况说明与新系统替代分析.md)、[`旧系统MongoDB替代分析-任务接续说明.md`](./旧系统MongoDB替代分析-任务接续说明.md) |
 | **T-API** | 旧 Business API 等价迁移 | [`旧Business API全量等价迁移任务书.md`](./旧Business%20API全量等价迁移任务书.md) | [`旧Business API迁移映射清单.md`](./旧Business%20API迁移映射清单.md)、[`旧Business API等价验收矩阵.md`](./旧Business%20API等价验收矩阵.md) |
 | **T-S1-DW** | S1 数据中台迁移(交接) | [`数据库迁移/S1/S1-任务交接记忆.md`](./数据库迁移/S1/S1-任务交接记忆.md) | [`数据库迁移/S1/S1数据中台迁移实施计划.md`](./数据库迁移/S1/S1数据中台迁移实施计划.md) |
@@ -29,6 +29,7 @@
 | **T-KPI** | KPI 原子层 / 看板筛选 | [`KPI原子聚合层与全局筛选适配执行任务书.md`](./KPI原子聚合层与全局筛选适配执行任务书.md) | [`看板查询条件现状分析.md`](./看板查询条件现状分析.md)、[`动态运营详情看板与指标改善闭环方案.md`](./动态运营详情看板与指标改善闭环方案.md) |
 | **T-PHASE** | 数据库迁移 Phase 0–6 | [`数据库迁移-Phase0-6实施任务包.md`](./数据库迁移-Phase0-6实施任务包.md) | [`新系统数据库设计方案.md`](./新系统数据库设计方案.md) |
 | **T-PERF** | 业务动作性能优化 | [`S1-评审与物料需求-数据中台全量刷新异步化任务书.md`](./S1-评审与物料需求-数据中台全量刷新异步化任务书.md) | [`S1-S4业务功能测试结果汇总_20260712.md`](./S1-S4业务功能测试结果汇总_20260712.md) |
+| **T-CAP** | 库容量与增量治理 | [`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) §4 | 同左(含快照、增量实测、分层治理方案) |
 
 ---
 
@@ -48,6 +49,7 @@
 | P-008 | T-S0 | ⬜ 待办 | P1 | CodeFirst 启动与 legacy BOM 表治理,降低启动噪音 | [`S0-CodeFirst启动异常与BOM历史数据治理方案.md`](./S0-CodeFirst启动异常与BOM历史数据治理方案.md) |
 | P-009 | T-KPI | ⬜ 待办 | P2 | 看板查询条件与 KPI 原子聚合层适配 | [`KPI原子聚合层与全局筛选适配执行任务书.md`](./KPI原子聚合层与全局筛选适配执行任务书.md) |
 | P-010 | T-PERF | ✅ 已完成 | P0 | 订单评审 / 生成物料需求末尾全量数据中台 ETL 改后台异步 + 单飞去抖;§6 验证通过(评审 ~3s,ORDER_REVIEW 后台 SUCCESS,3 次连评仅 2 轮全量) | [`S1-评审与物料需求-数据中台全量刷新异步化任务书.md`](./S1-评审与物料需求-数据中台全量刷新异步化任务书.md) |
+| P-011 | T-CAP | ⬜ 待办 | P1 | 库容量长期可控治理:P0 日志保留闭环(`mdp_sync_log`/`mdp_transform_run_log`/`SysLogOp`)、P1 大表 `dwd_process_outsource_delivery` 分区滚动、P2 调度强度与 KPI 原子日收口;统一保留配置 + 清理作业。**待定保留窗口取值与"丢弃/先归档"策略** | [`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) §4 |
 
 **图例**:⬜ 待办 · 🔄 进行中 · ✅ 已完成 · ⏸ 暂缓 · ❌ 取消
 
@@ -94,3 +96,4 @@ doc/plan/Ai-DOP项目待办清单.md
 | 2026-07-14 | 新增专题 **T-PERF** 与 **P-010**:订单评审 / 生成物料需求末尾全量 ETL 异步化任务书(性能优化,不改评审算法) |
 | 2026-07-14 | **P-010** 代码落地:新增 `MdpRefreshDispatcher`;评审/采购管线末尾 ETL 改为后台异步派发(单飞+去抖);状态 → 🔄 进行中(待 §6 功能验证) |
 | 2026-07-14 | **P-010** §6 验证通过:评审 3.21s;`ORDER_REVIEW` 后台 SUCCESS;连评去抖 3→2;状态 → ✅ 已完成 |
+| 2026-07-17 | 新增专题 **T-CAP** 与 **P-011**:库容量与增量可控治理方案(分层 P0–P2 + 统一保留机制),源自 `aidopdev_165库容量与增量写入分析_20260716.md` §4 |

BIN
doc/plan/S0-S2-报价与交付文档对比表.xlsx


BIN
doc/plan/S0-S2-报价与交付文档对比表_v2.xlsx


+ 88 - 0
doc/plan/S1/S1-报价与交付文档串联对比表.md

@@ -0,0 +1,88 @@
+# S1 产销协同 — 报价单与交付文档对比表
+
+**对比基准日期**:2026-06-22  
+
+**素材**
+
+| 类型 | 路径 |
+|------|------|
+| 报价单 | `…\项目报价\AI-DOP数字化运营平台开发报价清单_工时内容补充_增加算法_20260630.xlsx` |
+| 需求 | `…\交付文档\S1\S1产销协同业务需求描述.docx` |
+| 蓝图 | `…\交付文档\S1\S1产销协同模块蓝图设计方案.docx` |
+
+**已读报价 sheet**:`报价汇总-.NET方案`、`报价明细-.NET方案`、`算法清单`
+
+**图例(需求 / 蓝图列)**:✅ 有对应章节或内容 ❌ 无 ⚠️ 仅有相关业务描述、未按报价条目写全
+
+---
+
+## 汇总对比表
+
+| 报价单条目 | 条目内容 | 需求 | 蓝图 | 说明 |
+|------------|----------|:----:|:----:|------|
+| **【汇总·前端】产销协同** | 订单评审 | ✅ | ✅ | 对应(一)/ 第2章 订单评审管理 |
+| **【汇总·前端】产销协同** | 合同评审 | ❌ | ❌ | 🔴 **报有他无**:报价有,需求/蓝图均无独立章 |
+| **【汇总·前端】产销协同** | 产品设计 | ❌ | ❌ | 🔴 **报有他无**:报价有,需求/蓝图均无独立章 |
+| **【汇总·前端】产销协同** | 订单交付 | ✅ | ✅ | 对应(二)/ 第3章 |
+| **【汇总·前端】产销协同** | 发货 | ✅ | ✅ | 对应(三)订单发货 / 第4章 |
+| **【汇总·前端】产销协同** | 工单下达 | ✅ | ✅ | 对应(四)/ 第5章 |
+| **【汇总·前端】产销协同** | 计划联动看板 | ✅ | ✅ | 对应(五)/ 第6章 |
+| **【汇总·前端】产销协同** | 产销协同看板 | ✅ | ✅ | 对应(七)/ 第8章 |
+| **【汇总·后端】产销协同** | 销售订单录入变更与状态跟踪 | ✅ | ✅ | 写在订单评审相关流程中 |
+| **【汇总·后端】产销协同** | 合同/订单/设计评审流转 | ⚠️ | ⚠️ | 订单/合同评审有描述;**设计评审、合同评审无独立章** |
+| **【汇总·后端】产销协同** | 交期承诺与资源能力检查 | ✅ | ✅ | 订单评审章:交期评估、齐套与库存约束 |
+| **【汇总·后端】产销协同** | 发货计划编制与 ASN 生成 | ✅ | ✅ | 订单发货章 |
+| **【汇总·后端】产销协同** | 工单下达与生产计划联动 | ✅ | ✅ | 工单下达章 |
+| **【汇总·后端】产销协同** | 订单交付进度监控 | ✅ | ✅ | 订单交付章:全链路履约跟踪 |
+| **【汇总·后端】产销协同** | 发货达成与异常提醒 | ✅ | ⚠️ | 需求有发运跟踪;蓝图发货章偏流程,异常提醒需核对细度 |
+| **【汇总·后端】产销协同** | 产销协同看板指标汇总 | ✅ | ✅ | 产销协同看板章 + KPI 描述 |
+| **【汇总·数据库】产销协同** | 销售订单与合同及评审记录 | ⚠️ | ⚠️ | 订单评审有;**合同评审记录缺独立需求/蓝图章** |
+| **【汇总·数据库】产销协同** | 产品设计与评审资料 | ❌ | ❌ | 🔴 **报有他无**:报价承诺库表,Word 无产品设计章 |
+| **【汇总·数据库】产销协同** | 发货计划/交货明细/ASN 信息 | ✅ | ✅ | 订单发货相关 |
+| **【汇总·数据库】产销协同** | 工单下达与计划联动关系 | ✅ | ✅ | 工单下达章 |
+| **【汇总·数据库】产销协同** | 订单交付进度/延期/缺料等执行状态 | ✅ | ✅ | 订单交付、需求明细核验 |
+| **【汇总·数据库】产销协同** | 产销看板所需汇总指标 | ✅ | ✅ | 产销协同看板章 |
+| **【明细】项目准备** | 产销协同蓝图设计(11 人天) | — | ✅ | 对应《模块蓝图设计方案》全文;非按条拆分 |
+| **【明细】功能开发** | 订单管理(2 人天) | ⚠️ | ⚠️ | 覆盖 001–005 多能力;**001/002 无 Word 章**,其余有 |
+| **【明细】功能开发** | 工单管理(2 人天) | ✅ | ✅ | 对应工单下达 |
+| **【明细】功能开发** | 计划管理(2 人天) | ⚠️ | ⚠️ | 对应计划联动 + 需求明细核验;**后者报价汇总前端未点名** |
+| **【明细】功能开发** | 产销协同看板(1 人天) | ✅ | ✅ | 对应(七)/ 第8章 |
+| **【明细】九宫格看板** | 产销协同(2 人天) | ❌ | ❌ | 🔴 **报有他无**:报价独立一行,Word 未单独体现与模块看板关系 |
+| **【算法清单】产销协同** | 订单资源检查算法 | ❌ | ❌ | 🔴 **报有他无**:003 有交期/资源业务描述,**未写该算法名** |
+| **【算法清单】产销协同** | 供需缺口模型算法 | ⚠️ | ⚠️ | (六)需求明细核验有缺料/差额;**未写该算法名** |
+| **【算法清单】产销协同** | 安全库存动态算法 | ❌ | ❌ | 🔴 **报有他无**:需求/蓝图**均无**对应表述 |
+| **【算法清单】产销协同** | 订单交期模拟算法 | ❌ | ❌ | 🔴 **报有他无**:003 有交期评估;**未写该算法名** |
+
+---
+
+## 🔴 报价有 · 需求/蓝图没有(汇总 9 类)
+
+| 报价单条目 | 条目内容 | 说明 |
+|------------|----------|------|
+| 汇总·前端 | 产品设计、合同评审 | Word 均无章节 |
+| 汇总·后端/库 | 设计评审、产品设计资料 | 随 001/002 缺失 |
+| 算法清单 | 4 项算法名称 | 均未按报价名写入;安全库存无任何表述 |
+| 明细 | 九宫格看板→产销协同 2 人天 | Word 未单独对照 |
+
+---
+
+## 他有报无(报价未列 · 文档有)
+
+| 条目内容 | 需求 | 蓝图 | 说明 |
+|----------|:----:|:----:|------|
+| 需求明细核验 | ✅ (六) | ✅ 第7章 | 报价汇总前端 **8 项中未单独列出**;明细「计划管理」可能覆盖 |
+
+---
+
+## 变更记录
+
+| 日期 | 版本 | 说明 |
+|------|------|------|
+| 2026-06-22 | V1.0 | 初版(含三联对照表) |
+| 2026-06-22 | V2.0 | 去掉三联列,合并为单表五列 |
+
+---
+
+## 相关文档
+
+- [交付文档综合要求.md](../../交付文档综合要求.md)

BIN
doc/plan/_quote_work_copy.xlsx


+ 56 - 9
doc/plan/aidopdev_165库容量与增量写入分析_20260716.md

@@ -148,17 +148,63 @@
 
 ---
 
-## 4. 风险与治理建议(供决策,未实施)
+## 4. 长期运行增量可控治理方案(供决策,未实施)
 
-| 优先级 | 建议 | 预期收益 |
-|--------|------|----------|
-| P0 | 为 `mdp_sync_log`、`mdp_transform_run_log` 增加保留天数 + 定时清理 | 阻止运行日志无限增长 |
-| P0 | 评估 `SysLogOp`:缩短保留、关闭非必要 Monitor、或归档冷数据 | 当前约 1.8 GB,对备份/磁盘最敏感之一 |
-| P1 | 评估 `dwd_process_outsource_delivery` 历史 `stat_date` 是否需归档/分区 | 单表约 4.3 GB,占库一半以上 |
-| P2 | 明确生产环境是否开启 S8 调度;若开启,同步打开日志清理 | 避免检测日志二次膨胀 |
-| P2 | 区分「开发 165」与正式库的调度强度(Bootstrap / 小时全量) | 降低开发库无意义重刷 |
+> 目标一句话:**给每一张"会累积"的表都定义「保留窗口 + 到期回收」的闭环**,使系统长期运行后总量收敛到 `Σ(各表 日增量 × 保留天数)`,而不是线性无限增长。
 
-> 说明:以上为分析结论与建议,**本文档不包含已落地的清理脚本或配置变更**。实施前需单独评审影响(看板、对账、排障查日志)。
+### 4.1 目标:把"线性增长"改成"稳态有界"
+
+| 增长模式 | 代表表 | 现状 | 目标稳态 |
+|----------|--------|------|----------|
+| 无界追加(日志) | `mdp_sync_log`、`mdp_transform_run_log` | 无清理,只涨不减 | 保留窗口内有界 |
+| 追加+日清(已受控但偏大) | `SysLogOp` | 180 天 ≈ 1.8 GB | 缩窗/降流后收敛到目标值 |
+| 按日快照累积 | `dwd_process_outsource_delivery`(4.3G)、`dwd_requirement_examine_detail`、`dwd_order_schedule_trans` | 随 `stat_date` 只增 | 分区滚动:热数据在线 + 冷数据归档/丢弃 |
+| 可覆盖重建 | `ado_smart_ops_kpi_atomic_day` | delete+insert,已有界 | 加日期窗口进一步收口 |
+| upsert 自控 | `mdp_stg_*` / `mdp_std_*` | 跟源数据走 | 只监控,不动 |
+
+> **关键前提(InnoDB)**:`DELETE` 不会把磁盘还给操作系统(仅标记可复用)。大表要真正"可回收",优先用 **分区 + `DROP PARTITION`**(秒级、真回收),而非依赖 DELETE + 停机 `OPTIMIZE TABLE`。
+
+### 4.2 分层治理方案
+
+**P0 — 运行日志闭环(收益快、风险最低)**
+
+| 建议 | 处理方式 | 预期收益 |
+|------|----------|----------|
+| `mdp_sync_log` / `mdp_transform_run_log` 增加保留清理 | 新增通用日志保留作业,按时间列删除超期行;保留窗口建议 **30–60 天**;如需趋势可清理前先按天聚合入小汇总表 | 封住"只涨不减"的两张运行日志 |
+| `SysLogOp` 降压 | 保留 180 → **60–90 天**;生产评估关闭非必要 DB 日志 / Monitor(`Logging.json`)从源头降日增 | 缓解当前 ~1.8 GB 备份/磁盘压力 |
+| `ado_s8_detection_log` | 若生产开 S8 调度,同步开 `AIDOP_S8_LOG_CLEANUP_ENABLED` | 避免检测日志二次膨胀 |
+
+**P1 — 数据中台大表滚动(治本,占库一半以上)**
+
+| 建议 | 处理方式 | 预期收益 |
+|------|----------|----------|
+| `dwd_process_outsource_delivery`(≈4.3G / 1800 万行) | 按 `stat_date` 做 **RANGE 月分区**,在线只留近 N 个月,过期 `DROP PARTITION`(需留档则先导出归档再 drop) | 单表体积从半个库收敛为固定量级 |
+| 其它按日累积 `dwd_*`(`requirement_examine_detail` / `order_schedule_trans` 等) | 统一定义 `stat_date` 保留窗口并滚动清理;窗口须 **≥ 看板/KPI 实际回看天数** | 防止 DWD 历史无界增长且不影响展示 |
+
+**P2 — 调度与重建强度治理(防"无意义重刷")**
+
+| 建议 | 处理方式 | 预期收益 |
+|------|----------|----------|
+| 区分"开发 165"与正式库调度强度 | 开发库降低小时级全量 / `SmartOpsKpiBootstrap` 启动重刷频率 | 降低开发库无谓增量 |
+| `ado_smart_ops_kpi_atomic_day` 重建加日期窗口 | 进一步收口历史原子日 | 收敛 KPI 原子层 |
+
+**不动**:`mdp_stg_*` / `mdp_std_*`(upsert 自控,只监控);`InvTransHist`(外部流水,AiDOP 只读)。
+
+### 4.3 统一机制(避免散落多处)
+
+1. **一份保留策略配置**:`表名 → 时间列 → 保留天数 → 处理方式(delete / partition-drop / archive)`,集中管理,改窗口不改代码。
+2. **一个通用保留清理作业**:每日低峰跑一次,读配置逐表执行;分区表走 drop-partition,日志表走批量 delete。
+3. **容量监控**:把 §5 复测 SQL 做成每日采样 + 阈值告警(如库 > 12 GB、单表日增异常),让"增量可控"可观测。
+
+### 4.4 稳态效果
+
+封口后总量不再线性增长,而是收敛到 `Σ(各表 日增量 × 保留天数)`。例如日志类保留 60 天、`dwd_process_outsource_delivery` 保留 6 个月,则主力表都停在固定量级,长期运行库总量基本平稳。
+
+### 4.5 待办登记
+
+本方案已登记为项目待办 **P-011 / 专题 T-CAP(库容量与增量治理)**,见 [`Ai-DOP项目待办清单.md`](./Ai-DOP项目待办清单.md) §3。
+
+> 说明:以上为分析结论与方案,**本文档不包含已落地的清理脚本或配置变更**。实施前需单独评审影响(看板、对账、排障查日志),并先确定各表保留窗口取值与"冷数据丢弃 / 先归档再删"策略。
 
 ---
 
@@ -223,3 +269,4 @@ ORDER BY d DESC;
 | 日期 | 说明 |
 |------|------|
 | 2026-07-16 | 初版:165/`aidopdev` 容量快照 + 增量写入机制与实测 |
+| 2026-07-17 | §4 扩写为"长期运行增量可控治理方案"(稳态有界 / 分层 P0–P2 / 统一保留机制 / 稳态效果);登记待办 P-011(T-CAP) |

+ 304 - 0
doc/交付文档综合要求.md

@@ -0,0 +1,304 @@
+# 交付文档综合要求
+
+本文汇总 Ai-DOP **报价—需求—蓝图—测试** 四类交付物的一体化串联要求,以及各模块**正式交付 Word 文档**(业务需求描述、业务蓝图/设计方案)在命名、版式、编号、版本与工具链上的统一要求。适用于 **S0–S4** 及后续模块扩展。
+
+**总原则**:**报价单为项目范围基准**;需求文档、蓝图设计文档、测试文档须与报价单中的模块与功能项**整体串起来、一一可追溯**,不得出现范围偏差(多写、漏写、名称/边界不一致)。
+
+**批处理脚本(本仓库)**:`doc/_*.py`;飞书读写见 [feishu/MCP集成说明.md](./feishu/MCP集成说明.md)。
+
+**现行素材路径**见下节(**可能变动**,变更后须同步更新本文并重新做 §1 范围勾对)。
+
+---
+
+## 素材路径(现行,可能变动)
+
+> 本节记录当前使用的报价单与交付文档**本地 OneDrive 路径**。商务换版、目录调整或文件名变更时,**以最新有效路径为准**,并在此更新「最后确认日期」。
+
+| 用途 | 现行路径 | 说明 |
+|------|----------|------|
+| **报价单(范围基准)** | `C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目报价\AI-DOP数字化运营平台开发报价清单_工时内容补充_增加算法_20260630.xlsx` | 含模块/功能/工时等人天明细;REQ/FUNC/TC 均须与此清单可对应 |
+| **交付文档根目录** | `C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档` | 各模块 `S0`…`S4` 下需求/蓝图 Word、三联对照表等;子目录如 `S1\更新\` 同属交付范围 |
+
+**最后确认日期**:2026-06-22  
+
+**路径变更时建议动作**:
+
+1. 更新上表路径与「最后确认日期」  
+2. 核对新版报价单与 `交付文档` 内各模块三联对照表、REQ/FUNC/TC 是否仍一致(§1.2、§10.1)  
+3. 若脚本中写死了旧路径(如 `doc/_*.py`),同步改 `BASE` 常量或改为读环境变量  
+
+---
+
+## 1. 总要求:四类文档与报价单一体串联
+
+### 1.1 文档链与基准
+
+| 顺序 | 文档类型 | 典型载体 | 编号/标识 | 作用 |
+|------|----------|----------|-----------|------|
+| **基准** | **报价单** | 见上文「素材路径」:`AI-DOP数字化运营平台开发报价清单_…20260630.xlsx` | 模块 / 功能行 / 工时或金额行 | **定义本项目交付范围**,其它文档均不得偏离 |
+| 1 | **需求文档** | 各模块《业务需求描述》Word | `REQ-S{n}-nnn` | 把报价项落实为可验收的业务需求 |
+| 2 | **蓝图设计文档** | 各模块《业务蓝图/设计方案》Word | `FUNC-S{n}-nnn` | 把需求落实为功能方案与流程设计 |
+| 3 | **测试文档** | 测试用例、UAT 方案/报告等 | `TC-S{n}-nnn` | 验证 REQ/FUNC 已实现,支撑验收 |
+
+**串联纽带**:各模块 **三联对照表**(`Sx-三联对照表.xlsx`)维护 **报价条目 ↔ REQ ↔ FUNC ↔ TC** 对应关系;S3/S4 等暂无 xlsx 时,以对照表规则 + `aidopFuncCodes.ts` 保持一致。
+
+```mermaid
+flowchart LR
+  Q[报价单<br/>范围基准]
+  R[需求文档<br/>REQ-Sn-nnn]
+  F[蓝图设计<br/>FUNC-Sn-nnn]
+  T[测试文档<br/>TC-Sn-nnn]
+  Q --> R --> F --> T
+  Q -.->|三联对照表| R
+  R -.->|三联对照表| F
+  F -.->|三联对照表| T
+```
+
+### 1.2 一致性要求(不得有偏差)
+
+1. **以报价为准**:需求、蓝图、测试中出现的**模块、功能名称、边界**须能在报价单中找到对应项;**不得**写入报价未包含的功能或模块。  
+2. **不得遗漏**:报价单中已确认纳入本项目的功能,须在需求中有 REQ、在蓝图中有 FUNC(或明确说明合并到哪一条);进入 UAT/验收阶段的须有 TC 或等效测试覆盖。  
+3. **名称与粒度一致**:三联对照表、Word 章节标题、报价行项名称应**语义一致**;若合并或拆分条目,须在对照表中注明映射关系,不得 silently 改范围。  
+4. **变更同步**:报价变更 → 须同步修订 REQ / FUNC / TC 及三联对照表;仅改 Word 不改对照表、或仅改代码不改文档,均视为**范围偏差**。  
+5. **实现可追溯**:已上线菜单/页面须能在 `aidopFuncCodes.ts` 与 FUNC 编号对应;测试用例须能追溯到 REQ/FUNC 及报价项。
+
+### 1.3 对照与评审
+
+| 活动 | 说明 |
+|------|------|
+| **新建/大改模块** | 先从报价单提取该模块功能清单,再写 REQ → FUNC → TC,并更新三联对照表 |
+| **交付前评审** | 按 §1.2 做「报价 ↔ 三联表 ↔ 需求/蓝图/测试」勾对;有差异须先澄清是报价变更还是文档错误 |
+| **历史差异参考** | 规格书与报价差异初筛见 `ai-dop-platform/docs/spec_quote_gap_report.md`(示例方法,新模块可仿照做核对) |
+
+### 1.4 与版式要求的关系
+
+- 本节为**内容与范围**的总要求,**优先级高于**单篇 Word 的版式细节。  
+- §3 及以后各节主要规范**需求/蓝图 Word** 的封面、版本、分页与 REQ/FUNC 标注;测试文档、报价单可沿用各自模板,但**编号与三联对照表必须对齐**。
+
+---
+
+## 2. 文档范围
+
+### 2.1 纳入统一要求的文档
+
+| 类型 | 文件名惯例 | 每模块数量 | 与报价单关系 |
+|------|------------|------------|--------------|
+| **报价单(项目级)** | 《…开发报价清单》`.xlsx` 等 | 1(或按商务版本) | **范围基准** |
+| 业务需求描述 | `S{n}…业务需求描述.docx` | 1 | 覆盖本模块报价功能 → REQ |
+| 业务蓝图 / 设计方案 | `S{n}…模块蓝图设计方案.docx` 等 | 1 | 覆盖本模块 REQ → FUNC |
+| **测试文档** | 测试用例 / UAT 方案 / 测试报告等 | 按模块或专题 | 覆盖 REQ/FUNC → TC |
+| **三联对照表** | `S{n}-三联对照表.xlsx` | 1 | 报价项与 REQ/FUNC/TC 映射 |
+
+- 模块目录:`交付文档\S0` … `交付文档\S4`(根路径见上文「素材路径」;报价单在 `项目\项目报价\` 下,与交付目录分离存放)
+- 子目录(如 `S1\更新\`)中**同类型正式交付件**适用相同要求
+- 编号、版式与根目录主文档保持一致
+
+### 2.2 暂不纳入的对象
+
+| 对象 | 说明 |
+|------|------|
+| `Tmp\` 下功能设计说明、需求说明书等 | 工作底稿 / 从代码整理的说明,无统一封面结构;若引用为正式范围须回写 REQ/对照表 |
+| 会议纪要、方案 Markdown | 走 `doc/` 目录规范,非 Word 交付件 |
+| 三联对照表 `.xlsx` | 本身不是交付 Word,但是 **§1 串联的核心数据源**,必须与报价单同步维护 |
+
+---
+
+## 3. 系统与文档标识
+
+### 3.1 系统名称(全文统一)
+
+**唯一标准写法**:
+
+```text
+Ai-DOP智慧运营管理系统
+```
+
+- 不使用「Ai-DOP 智慧运营决策平台」等历史名称
+- 封面第三行、正文首次出现系统名处均应一致
+
+### 3.2 文档编号
+
+| 文档类型 | 编号规则 | 示例 |
+|----------|----------|------|
+| 业务需求描述 | `S{n}-BRD-001` | S1-BRD-001 |
+| 业务蓝图设计方案 | `S{n}-BBP-001` | S0-BBP-001 |
+
+- 编号出现在封面信息表中「文档编号」行
+- 同模块同类型仅一份主交付件时序号为 `001`;新增独立交付件再递增
+
+### 3.3 封面信息表(5 行)
+
+封面标题区下方为 **2 列 × 5 行** 表格(Table Grid 样式),字段固定:
+
+| 字段 | 填写要求 |
+|------|----------|
+| 文档编号 | 见 §3.2 |
+| 版本 | 与版本记录中**当前版本**一致,统一为 **V0.2**(见 §4) |
+| 密级 | **内部** |
+| 编制单位 | 默认 **智造易项目组**(历史作者信息可保留在版本记录「修订人」) |
+| 编制日期 | 最近修订日期,格式 `YYYY-MM-DD` |
+
+### 3.4 封面标题区(3 行居中)
+
+1. **第 1 行**:模块 + 主题短名(如 `S1 产销协同模块`、`S0 基础主数据建模`)— 28pt、加粗、蓝色 `#1F4E79`、居中  
+2. **第 2 行**:文档类型(`业务需求描述` / `业务蓝图设计方案`)— 22pt、加粗、蓝色、居中  
+3. **第 3 行**:`Ai-DOP智慧运营管理系统` — 13pt、黑色、居中  
+
+字体:**微软雅黑**。
+
+---
+
+## 4. 版本记录
+
+### 4.1 章节名称与位置
+
+- 标题为 **「版本记录」**(不使用「更改记录」「文档控制」)
+- **单独占一页**:标题前插入分页符
+- **不得**再保留「文档控制」章节及下属审核/发布表
+
+### 4.2 版本表内容(仅两条)
+
+表头:**日期 | 版本 | 修订人 | 变更说明**(表头行蓝底 `#1F3864`、白字、居中)
+
+| 版本 | 变更说明 | 说明 |
+|------|----------|------|
+| V0.1 | 初版建立 | 首版日期、修订人按模块实际 |
+| V0.2 | 增加版本号 | 统一 REQ/FUNC 编号与版式后的当前版 |
+
+- 封面「版本」字段与版本记录 **V0.2** 一致
+- 不在版本表中追加其它说明性条目(业务变更另走变更流程或新版本文档)
+
+### 4.3 已删除内容(禁止回退)
+
+以下自 S0–S4 统一版式起**不再使用**:
+
+- 「文档控制」整节  
+- 「审核」「发布」及甲方/乙方项目经理签字表  
+- 封面散落的「文档作者 / 创建日期 / 当前版本」多行元数据(已收入封面表 + 版本记录)
+
+---
+
+## 5. 目录
+
+- 标题 **「目录」** **单独占一页**(目录标题前插入分页符)
+- 打开 Word 后需 **更新域**(右键目录 → 更新域,或全选后 **F9**)
+- 目录中**不应**再出现「文档控制」「更改记录」等已删除章节
+
+---
+
+## 6. 分页结构(Front Matter 总览)
+
+正式交付 Word 前几页固定为 **三页**:
+
+```text
+第 1 页  封面(标题三行 + 信息表)
+第 2 页  版本记录(标题 + 版本表)
+第 3 页  目录
+第 4 页起  正文
+```
+
+---
+
+## 7. REQ / FUNC / TC 编号
+
+### 7.1 原则
+
+- **只追加编号,不改写**原有业务表述与版式(除 Front Matter 统一外)
+- 编号与模块 **三联对照表** 或 `Web/src/constants/aidopFuncCodes.ts` 对齐
+- **REQ、FUNC、TC 须与报价单条目可对应**(见 §1)
+- 禁止擅自改号、重排已有 FUNC 序号
+
+### 7.2 标注位置
+
+| 文档 | 标注方式 |
+|------|----------|
+| 业务需求描述 | 章节标题或功能条目前追加 `(REQ-S{n}-nnn)` |
+| 蓝图 / 设计方案 | 章节标题或功能方案条目前追加 `(FUNC-S{n}-nnn)` |
+| 测试文档 | 用例或场景标题关联 `TC-S{n}-nnn`,并注明对应 REQ/FUNC |
+
+- 文档中无对应章节的 REQ/FUNC/TC **不强行插入**;但报价单已有项**不得**长期缺失 REQ 或测试覆盖
+- S3/S4 暂无 xlsx 三联表时,以 `aidopFuncCodes.ts` 及 S1 对齐规则为准
+
+### 7.3 数据源
+
+| 模块 | 三联对照表路径(交付目录) | 须包含列 |
+|------|---------------------------|----------|
+| S0–S2 | `S{n}\S{n}-三联对照表.xlsx` | 报价/功能名、REQ、FUNC、TC(及备注映射) |
+| S3、S4 | 内置对照 + 待补 xlsx | 同上原则 |
+
+---
+
+## 8. 正文与其它格式
+
+- **正文结构、图表、原型截图**:以各模块已定稿为准;内容须满足 §1 与报价单一致  
+- 功能设计说明、需求说明书(Tmp 或独立 docx):REQ/FUNC 仍须登记,但**不要求** §3–§6 封面三页结构  
+- 新增 **Ai-DOP 业务叶子菜单** 时,须同步维护 FUNC 登记,见仓库 `.cursor/rules/aidop-func-code-menu.mdc`
+
+---
+
+## 9. 工具链与脚本
+
+### 9.1 推荐处理顺序(旧版 Word → 统一版)
+
+1. `_add_req_func_codes.py` — 追加 REQ/FUNC 编号  
+2. `_unify_doc_format.py` — **慎用**:会重建 Front Matter,易丢失手工格式;仅在对旧版做一次性转换时使用  
+3. `_restore_cover_format.py` — 恢复封面居中、蓝标题、表格样式  
+4. `_unify_version_records.py` — 版本表统一为 V0.1 / V0.2  
+5. `_adjust_front_matter.py` — 删「文档控制」、改「版本记录」、分页  
+6. `_extend_delivery_front_matter.py` — 递归扫描 S0–S4,自动判断需重建或仅分页  
+
+### 9.2 脚本入口约定
+
+- 默认处理 `交付文档\S0` … `S4` 下含 **「目录」** 的 `.docx`  
+- 无目录的工作底稿自动跳过  
+
+### 9.3 飞书协同
+
+- 云盘 Office 文件:**feishu-drive** MCP(下载 → 本地脚本 → 上传覆盖)  
+- 飞书在线文档:**feishu-openapi** 官方 MCP  
+- 详见 [feishu/MCP集成说明.md](./feishu/MCP集成说明.md)
+
+---
+
+## 10. 交付前自检清单
+
+### 10.1 范围串联(§1)
+
+- [ ] 已对照当前有效**报价单**,本模块功能清单无遗漏、无超范围  
+- [ ] **三联对照表**中报价项/功能名与 REQ、FUNC、TC 一一可映射  
+- [ ] 需求文档每条 REQ 能在报价单中找到依据;蓝图 FUNC 覆盖对应 REQ  
+- [ ] 进入验收的条目有 TC(或等效测试记录)且可追溯到 REQ/FUNC  
+- [ ] 近期范围变更已同步更新报价单或文档,无「只改一端」  
+
+### 10.2 版式与编号(§3–§7)
+
+- [ ] 系统名称为 `Ai-DOP智慧运营管理系统`  
+- [ ] 封面三行 + 5 行信息表完整,文档编号符合 `Sx-BRD/BBP-001`  
+- [ ] 无「文档控制」「审核」「发布」  
+- [ ] 「版本记录」单独一页,且仅 V0.1 / V0.2 两条  
+- [ ] 「目录」单独一页,已更新域  
+- [ ] 三联对照表中已有条目均在正文对应章节标注 REQ/FUNC  
+- [ ] 未擅自修改已有 FUNC 编号  
+- [ ] 打开 Word 抽检:分页、表格样式、目录条目正常  
+
+---
+
+## 11. 变更记录(本文)
+
+| 日期 | 版本 | 修订人 | 说明 |
+|------|------|--------|------|
+| 2026-06-22 | V1.0 | 智造易项目组 | 初版:汇总 S0–S4 交付 Word 统一要求与工具链 |
+| 2026-06-22 | V1.1 | 智造易项目组 | 增加 §1:报价单—需求—蓝图—测试一体串联、不得范围偏差 |
+| 2026-06-22 | V1.2 | 智造易项目组 | 增加 §素材路径:现行报价单与交付文档 OneDrive 路径(可能变动) |
+
+---
+
+## 12. 相关文档
+
+| 文档 | 说明 |
+|------|------|
+| [feishu/MCP集成说明.md](./feishu/MCP集成说明.md) | 飞书文档 MCP 配置 |
+| [README.md](./README.md) | 本仓库 `doc/` 目录约定 |
+| `Web/src/constants/aidopFuncCodes.ts` | FUNC 编号与路由登记 |
+| `.cursor/rules/aidop-func-code-menu.mdc` | 菜单与 FUNC 展示规则 |
+| `ai-dop-platform/docs/spec_quote_gap_report.md` | 规格书与报价表差异核对示例 |

+ 15 - 0
tools/feishu-drive-mcp/.env.example

@@ -0,0 +1,15 @@
+# 飞书 App ID(开放平台 → 凭证与基础信息)
+FEISHU_APP_ID=cli_xxxxxxxx
+
+# 飞书 App Secret
+FEISHU_APP_SECRET=xxxxxxxx
+
+# API 域名(国内飞书保持默认)
+FEISHU_DOMAIN=https://open.feishu.cn
+
+# 默认云盘文件夹 token(链接中 folder/ 后的 ID)
+# 例:https://xxx.feishu.cn/drive/folder/Ks1RfygMblbsNEdB3JdcxHCnnFb
+FEISHU_DEFAULT_FOLDER_TOKEN=Ks1RfygMblbsNEdB3JdcxHCnnFb
+
+# MCP 下载/上传允许访问的本地根目录(默认仓库根)
+FEISHU_WORKSPACE_ROOT=D:\Projects\Ai-DOP\SourceCode\ZZYDOP

+ 244 - 0
tools/feishu-drive-mcp/feishu-client.js

@@ -0,0 +1,244 @@
+import fs from "node:fs";
+import path from "node:path";
+import { pipeline } from "node:stream/promises";
+import { Readable } from "node:stream";
+
+const DEFAULT_DOMAIN = "https://open.feishu.cn";
+
+export class FeishuClient {
+  /** @param {{ appId: string; appSecret: string; domain?: string }} options */
+  constructor(options) {
+    this.appId = options.appId;
+    this.appSecret = options.appSecret;
+    this.domain = (options.domain || DEFAULT_DOMAIN).replace(/\/$/, "");
+    this.token = null;
+    this.tokenExpireAt = 0;
+  }
+
+  static fromEnv() {
+    const appId = process.env.FEISHU_APP_ID;
+    const appSecret = process.env.FEISHU_APP_SECRET;
+    if (!appId || !appSecret) {
+      throw new Error("缺少 FEISHU_APP_ID 或 FEISHU_APP_SECRET 环境变量");
+    }
+    return new FeishuClient({
+      appId,
+      appSecret,
+      domain: process.env.FEISHU_DOMAIN || DEFAULT_DOMAIN,
+    });
+  }
+
+  async getTenantAccessToken() {
+    const now = Date.now();
+    if (this.token && now < this.tokenExpireAt - 60_000) {
+      return this.token;
+    }
+    const res = await fetch(
+      `${this.domain}/open-apis/auth/v3/tenant_access_token/internal`,
+      {
+        method: "POST",
+        headers: { "Content-Type": "application/json; charset=utf-8" },
+        body: JSON.stringify({
+          app_id: this.appId,
+          app_secret: this.appSecret,
+        }),
+      }
+    );
+    const data = await res.json();
+    if (data.code !== 0) {
+      throw new Error(`获取 tenant_access_token 失败: ${data.msg || res.status}`);
+    }
+    this.token = data.tenant_access_token;
+    this.tokenExpireAt = now + (data.expire || 7200) * 1000;
+    return this.token;
+  }
+
+  async request(method, apiPath, { query, body, raw = false } = {}) {
+    const token = await this.getTenantAccessToken();
+    const url = new URL(`${this.domain}${apiPath}`);
+    if (query) {
+      for (const [k, v] of Object.entries(query)) {
+        if (v !== undefined && v !== null && v !== "") {
+          url.searchParams.set(k, String(v));
+        }
+      }
+    }
+    const headers = { Authorization: `Bearer ${token}` };
+    let payload = body;
+    if (body && !(body instanceof FormData)) {
+      headers["Content-Type"] = "application/json; charset=utf-8";
+      payload = JSON.stringify(body);
+    }
+    const res = await fetch(url, { method, headers, body: payload });
+    if (raw) {
+      return res;
+    }
+    const data = await res.json();
+    if (data.code !== 0) {
+      throw new Error(`${apiPath} 失败 (${data.code}): ${data.msg}`);
+    }
+    return data.data;
+  }
+
+  async listFolder(folderToken, pageToken) {
+    return this.request("GET", "/open-apis/drive/v1/files", {
+      query: {
+        folder_token: folderToken,
+        page_size: 200,
+        page_token: pageToken,
+      },
+    });
+  }
+
+  async getFileMeta(fileToken, docType) {
+    const tryTypes = docType
+      ? [docType]
+      : ["file", "docx", "doc", "sheet", "bitable", "slides", "folder"];
+    for (const type of tryTypes) {
+      try {
+        const data = await this.request(
+          "POST",
+          "/open-apis/drive/v1/metas/batch_query",
+          {
+            body: {
+              request_docs: [{ doc_token: fileToken, doc_type: type }],
+            },
+          }
+        );
+        const meta = data.metas?.[0];
+        if (meta) {
+          return {
+            ...meta,
+            token: fileToken,
+            type,
+            name: meta.title || meta.name || "",
+          };
+        }
+      } catch {
+        // try next doc type
+      }
+    }
+    throw new Error(`无法获取文件元数据: ${fileToken}`);
+  }
+
+  async downloadFile(fileToken, savePath) {
+    const token = await this.getTenantAccessToken();
+    const res = await fetch(
+      `${this.domain}/open-apis/drive/v1/files/${fileToken}/download`,
+      { headers: { Authorization: `Bearer ${token}` } }
+    );
+    if (!res.ok) {
+      const text = await res.text();
+      throw new Error(`下载失败 (${res.status}): ${text.slice(0, 300)}`);
+    }
+    await fs.promises.mkdir(path.dirname(savePath), { recursive: true });
+    await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(savePath));
+    const stat = await fs.promises.stat(savePath);
+    return { path: savePath, size: stat.size };
+  }
+
+  async uploadFile({ folderToken, localPath, fileName, replaceFileToken }) {
+    const stat = await fs.promises.stat(localPath);
+    const size = stat.size;
+    let name = fileName || path.basename(localPath);
+    let parentToken = folderToken;
+
+    if (replaceFileToken) {
+      const meta = await this.getFileMeta(replaceFileToken);
+      parentToken = meta.parent_token || meta.parent_node || folderToken;
+      if (!name || name === path.basename(localPath)) {
+        name = meta.name || name;
+      }
+    }
+
+    if (!parentToken) {
+      throw new Error("需要 folderToken 或有效的 replaceFileToken(含 parent_token)");
+    }
+
+    const form = new FormData();
+    form.append("file_name", name);
+    form.append("parent_type", "explorer");
+    form.append("parent_node", parentToken);
+    form.append("size", String(size));
+    form.append(
+      "file",
+      new Blob([await fs.promises.readFile(localPath)]),
+      name
+    );
+
+    const data = await this.request("POST", "/open-apis/drive/v1/files/upload_all", {
+      body: form,
+    });
+
+    if (replaceFileToken) {
+      try {
+        await this.deleteFile(replaceFileToken);
+      } catch (err) {
+        return {
+          ...data,
+          warning: `新文件已上传,但删除旧文件失败: ${err.message}`,
+        };
+      }
+    }
+    return data;
+  }
+
+  async deleteFile(fileToken) {
+    return this.request("DELETE", `/open-apis/drive/v1/files/${fileToken}`, {});
+  }
+
+  /** 导出在线文档/表格为 Office 文件并下载到本地 */
+  async exportAndDownload({ token, type, fileExtension, savePath }) {
+    let task = await this.request("POST", "/open-apis/drive/v1/export_tasks", {
+      body: {
+        token,
+        type,
+        file_extension: fileExtension,
+      },
+    });
+
+    const ticket = task.ticket;
+    for (let i = 0; i < 60; i += 1) {
+      await sleep(1500);
+      const status = await this.request(
+        "GET",
+        `/open-apis/drive/v1/export_tasks/${ticket}`
+      );
+      if (status.result?.file_token) {
+        return this.downloadFile(status.result.file_token, savePath);
+      }
+      const jobStatus = status.result?.job_status;
+      if (jobStatus === 2 || jobStatus === 3) {
+        throw new Error(`导出失败: ${JSON.stringify(status.result)}`);
+      }
+    }
+    throw new Error("导出超时,请稍后重试");
+  }
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/** 根据飞书 file type 推断 export_tasks 的 type 参数 */
+export function inferExportType(feishuType) {
+  const map = {
+    docx: { type: "docx", extensions: ["docx", "pdf"] },
+    doc: { type: "doc", extensions: ["docx", "pdf"] },
+    sheet: { type: "sheet", extensions: ["xlsx", "csv"] },
+    bitable: { type: "bitable", extensions: ["xlsx", "csv"] },
+    slides: { type: "slides", extensions: ["pptx", "pdf"] },
+  };
+  return map[feishuType] || null;
+}
+
+export function resolveWorkspacePath(inputPath, workspaceRoot) {
+  const root = workspaceRoot || process.cwd();
+  const resolved = path.isAbsolute(inputPath)
+    ? path.normalize(inputPath)
+    : path.normalize(path.join(root, inputPath));
+  if (!resolved.startsWith(path.normalize(root))) {
+    throw new Error(`路径必须在工作区内: ${inputPath}`);
+  }
+  return resolved;
+}

+ 1176 - 0
tools/feishu-drive-mcp/package-lock.json

@@ -0,0 +1,1176 @@
+{
+  "name": "feishu-drive-mcp",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "feishu-drive-mcp",
+      "version": "0.1.0",
+      "dependencies": {
+        "@modelcontextprotocol/sdk": "^1.12.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@hono/node-server": {
+      "version": "1.19.14",
+      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+      "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.14.1"
+      },
+      "peerDependencies": {
+        "hono": "^4"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk": {
+      "version": "1.29.0",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
+      "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@hono/node-server": "^1.19.9",
+        "ajv": "^8.17.1",
+        "ajv-formats": "^3.0.1",
+        "content-type": "^1.0.5",
+        "cors": "^2.8.5",
+        "cross-spawn": "^7.0.5",
+        "eventsource": "^3.0.2",
+        "eventsource-parser": "^3.0.0",
+        "express": "^5.2.1",
+        "express-rate-limit": "^8.2.1",
+        "hono": "^4.11.4",
+        "jose": "^6.1.3",
+        "json-schema-typed": "^8.0.2",
+        "pkce-challenge": "^5.0.0",
+        "raw-body": "^3.0.0",
+        "zod": "^3.25 || ^4.0",
+        "zod-to-json-schema": "^3.25.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@cfworker/json-schema": "^4.1.1",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "@cfworker/json-schema": {
+          "optional": true
+        },
+        "zod": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^2.0.0",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "iconv-lite": "^0.7.2",
+        "on-finished": "^2.4.1",
+        "qs": "^6.15.2",
+        "raw-body": "^3.0.2",
+        "type-is": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/eventsource": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+      "license": "MIT",
+      "dependencies": {
+        "eventsource-parser": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/eventsource-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
+      "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express-rate-limit": {
+      "version": "8.5.2",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+      "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
+      "license": "MIT",
+      "dependencies": {
+        "ip-address": "^10.2.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
+    "node_modules/fast-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+      "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hono": {
+      "version": "4.12.27",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
+      "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.9.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ip-address": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+      "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jose": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+      "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
+    "node_modules/json-schema-typed": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+      "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pkce-challenge": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+      "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.25.2",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+      "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "zod": "^3.25.28 || ^4"
+      }
+    }
+  }
+}

+ 16 - 0
tools/feishu-drive-mcp/package.json

@@ -0,0 +1,16 @@
+{
+  "name": "feishu-drive-mcp",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Feishu cloud drive MCP for Office files (Word/Excel/PPT) download and upload",
+  "scripts": {
+    "start": "node server.js"
+  },
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.12.1"
+  },
+  "engines": {
+    "node": ">=18"
+  }
+}

+ 242 - 0
tools/feishu-drive-mcp/server.js

@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import {
+  CallToolRequestSchema,
+  ListToolsRequestSchema,
+  McpError,
+  ErrorCode,
+} from "@modelcontextprotocol/sdk/types.js";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+  FeishuClient,
+  inferExportType,
+  resolveWorkspacePath,
+} from "./feishu-client.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const WORKSPACE_ROOT =
+  process.env.FEISHU_WORKSPACE_ROOT ||
+  path.resolve(__dirname, "..", "..");
+const DEFAULT_FOLDER = process.env.FEISHU_DEFAULT_FOLDER_TOKEN || "";
+
+let client;
+try {
+  client = FeishuClient.fromEnv();
+} catch (error) {
+  console.error("[feishu-drive-mcp] 初始化失败:", error.message);
+  process.exit(1);
+}
+
+function textResult(obj) {
+  return {
+    content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
+  };
+}
+
+function toolError(message) {
+  return {
+    isError: true,
+    content: [{ type: "text", text: message }],
+  };
+}
+
+const server = new Server(
+  { name: "feishu-drive-mcp", version: "0.1.0" },
+  { capabilities: { tools: {} } }
+);
+
+server.setRequestHandler(ListToolsRequestSchema, async () => ({
+  tools: [
+    {
+      name: "feishu_list_folder",
+      description:
+        "列出飞书云盘文件夹内容。返回 file_token、名称、类型(docx/sheet/file/folder 等)。folder_token 不传则用 FEISHU_DEFAULT_FOLDER_TOKEN。",
+      inputSchema: {
+        type: "object",
+        properties: {
+          folder_token: {
+            type: "string",
+            description: "文件夹 token,如链接 Ks1RfygMblbsNEdB3JdcxHCnnFb",
+          },
+          page_token: { type: "string", description: "分页 token" },
+        },
+        required: [],
+      },
+    },
+    {
+      name: "feishu_get_file_meta",
+      description: "获取云盘文件或在线文档的元信息(名称、类型、父文件夹等)",
+      inputSchema: {
+        type: "object",
+        properties: {
+          file_token: { type: "string", description: "文件 token" },
+          doc_type: {
+            type: "string",
+            description: "可选:file/docx/doc/sheet/bitable/slides/folder",
+          },
+        },
+        required: ["file_token"],
+      },
+    },
+    {
+      name: "feishu_download_file",
+      description:
+        "下载云盘中的 Office 文件(docx/xlsx/pptx 等 file 类型)到工作区相对路径或绝对路径",
+      inputSchema: {
+        type: "object",
+        properties: {
+          file_token: { type: "string" },
+          save_path: {
+            type: "string",
+            description: "保存路径,相对仓库根或绝对路径",
+          },
+        },
+        required: ["file_token", "save_path"],
+      },
+    },
+    {
+      name: "feishu_upload_file",
+      description:
+        "上传本地 Office 文件到飞书云盘文件夹;可选 replace_file_token 覆盖旧文件",
+      inputSchema: {
+        type: "object",
+        properties: {
+          local_path: { type: "string", description: "本地文件路径" },
+          folder_token: {
+            type: "string",
+            description: "目标文件夹 token,覆盖时可省略",
+          },
+          file_name: { type: "string", description: "上传后的文件名" },
+          replace_file_token: {
+            type: "string",
+            description: "若提供,上传到同目录后删除此旧文件",
+          },
+        },
+        required: ["local_path"],
+      },
+    },
+    {
+      name: "feishu_export_online_doc",
+      description:
+        "将飞书在线文档/表格/幻灯片导出为 Office 文件并下载(docx/xlsx/pptx/pdf 等)",
+      inputSchema: {
+        type: "object",
+        properties: {
+          file_token: { type: "string", description: "在线文档 token" },
+          file_extension: {
+            type: "string",
+            description: "导出格式:docx/pdf/xlsx/csv/pptx 等",
+          },
+          save_path: { type: "string", description: "保存路径" },
+        },
+        required: ["file_token", "file_extension", "save_path"],
+      },
+    },
+    {
+      name: "feishu_delete_file",
+      description: "删除云盘文件(慎用)",
+      inputSchema: {
+        type: "object",
+        properties: {
+          file_token: { type: "string" },
+        },
+        required: ["file_token"],
+      },
+    },
+  ],
+}));
+
+server.setRequestHandler(CallToolRequestSchema, async (request) => {
+  const { name, arguments: args } = request.params;
+  try {
+    switch (name) {
+      case "feishu_list_folder": {
+        const folderToken = args?.folder_token || DEFAULT_FOLDER;
+        if (!folderToken) {
+          throw new McpError(
+            ErrorCode.InvalidParams,
+            "请提供 folder_token 或配置 FEISHU_DEFAULT_FOLDER_TOKEN"
+          );
+        }
+        const data = await client.listFolder(folderToken, args?.page_token);
+        return textResult(data);
+      }
+      case "feishu_get_file_meta": {
+        const data = await client.getFileMeta(
+          args.file_token,
+          args.doc_type
+        );
+        return textResult(data);
+      }
+      case "feishu_download_file": {
+        const savePath = resolveWorkspacePath(args.save_path, WORKSPACE_ROOT);
+        const data = await client.downloadFile(args.file_token, savePath);
+        return textResult(data);
+      }
+      case "feishu_upload_file": {
+        const localPath = resolveWorkspacePath(args.local_path, WORKSPACE_ROOT);
+        const folderToken = args.folder_token || DEFAULT_FOLDER || undefined;
+        if (!folderToken && !args.replace_file_token) {
+          throw new McpError(
+            ErrorCode.InvalidParams,
+            "请提供 folder_token、replace_file_token 或配置 FEISHU_DEFAULT_FOLDER_TOKEN"
+          );
+        }
+        const data = await client.uploadFile({
+          folderToken,
+          localPath,
+          fileName: args.file_name,
+          replaceFileToken: args.replace_file_token,
+        });
+        return textResult(data);
+      }
+      case "feishu_export_online_doc": {
+        const meta = await client.getFileMeta(args.file_token);
+        const hint = inferExportType(meta.type);
+        if (!hint) {
+          throw new McpError(
+            ErrorCode.InvalidParams,
+            `类型 ${meta.type} 不支持导出,请用 feishu_download_file 或官方 lark-mcp 在线编辑`
+          );
+        }
+        if (!hint.extensions.includes(args.file_extension)) {
+          throw new McpError(
+            ErrorCode.InvalidParams,
+            `${meta.type} 支持的导出格式: ${hint.extensions.join(", ")}`
+          );
+        }
+        const savePath = resolveWorkspacePath(args.save_path, WORKSPACE_ROOT);
+        const data = await client.exportAndDownload({
+          token: args.file_token,
+          type: hint.type,
+          fileExtension: args.file_extension,
+          savePath,
+        });
+        return textResult({ ...data, source_type: meta.type, name: meta.name });
+      }
+      case "feishu_delete_file": {
+        await client.deleteFile(args.file_token);
+        return textResult({ deleted: args.file_token });
+      }
+      default:
+        throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
+    }
+  } catch (error) {
+    if (error instanceof McpError) {
+      throw error;
+    }
+    return toolError(error.message || String(error));
+  }
+});
+
+async function main() {
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
+}
+
+main().catch((error) => {
+  console.error("[feishu-drive-mcp] fatal:", error);
+  process.exit(1);
+});