_unify_ab_delivery_vnext.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753
  1. # -*- coding: utf-8 -*-
  2. """S0–S8 交付 Word:按 A/B 统一建议另存新版本(旧文件保留)。"""
  3. from __future__ import annotations
  4. import re
  5. import shutil
  6. import sys
  7. import tempfile
  8. from pathlib import Path
  9. from docx import Document
  10. from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
  11. from docx.oxml import OxmlElement
  12. from docx.oxml.ns import qn
  13. from docx.shared import Pt, RGBColor
  14. from docx.table import Table
  15. from docx.text.paragraph import Paragraph
  16. HERE = Path(__file__).resolve().parent
  17. sys.path.insert(0, str(HERE))
  18. from _add_req_func_codes import ( # noqa: E402
  19. S0_ALIASES,
  20. append_code,
  21. extract_heading_title,
  22. set_paragraph_text,
  23. )
  24. from _adjust_front_matter import ( # noqa: E402
  25. clear_page_breaks,
  26. ensure_page_break_before,
  27. remove_paragraph,
  28. )
  29. BASE = Path(r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档")
  30. TMP = Path(tempfile.gettempdir()) / "aidop_unify_ab"
  31. REPORT = HERE / "_tmp_unify_ab_report.txt"
  32. TODAY = "2026-09-04"
  33. SYS = "Ai-DOP智慧运营管理系统"
  34. AUTHOR = "智造易项目组"
  35. NOTE = (
  36. "统一系统名与版本记录;需求标题补 REQ;B 类需求补范围;"
  37. "S1 详设补部署/非功能与需求明细核验;数据中台改为数据对接服务;手册指引总纲"
  38. )
  39. SYS_REPL = [
  40. ("AI-DOP 智慧运营决策平台", SYS),
  41. ("Ai-DOP 智慧运营决策平台", SYS),
  42. ("Ai-DOP智慧运营决策平台", SYS),
  43. ("Ai-DOP 企业智慧运营管理系统", SYS),
  44. ("Ai-DOP企业智慧运营管理系统", SYS),
  45. ("Ai-DOP 智慧运营管理系统", SYS),
  46. ]
  47. DATA_REPL = [
  48. ("数据中台平台(Middle Data Platform)", "数据对接服务(Middle Data Platform)"),
  49. ("数据中台平台", "数据对接服务"),
  50. ("DOP 数据中台", "DOP 数据对接服务"),
  51. ("底层数据中台链路", "底层数据对接服务链路"),
  52. ("数据中台三层", "数据对接服务三层"),
  53. ("数据中台链路", "数据对接服务链路"),
  54. ("数据中台分层", "数据对接服务分层"),
  55. ("经数据中台", "经数据对接服务"),
  56. ("数据中台 T8", "数据对接服务 T8"),
  57. ("数据中台", "数据对接服务"),
  58. ]
  59. S0_CANON_REQ = {
  60. "标准 BOM 管理": "REQ-S0-001",
  61. "标准工艺路线管理": "REQ-S0-002",
  62. "标准工序管理": "REQ-S0-003",
  63. "工作中心管理": "REQ-S0-004",
  64. "产线管理": "REQ-S0-005",
  65. "产线物料管理": "REQ-S0-006",
  66. "产线工位管理": "REQ-S0-007",
  67. "人员技能管理": "REQ-S0-008",
  68. "人员技能分配": "REQ-S0-009",
  69. "前置工序要素管理": "REQ-S0-010",
  70. "前置工序要素参数管理": "REQ-S0-011",
  71. "生产要素参数管理": "REQ-S0-012",
  72. "工艺流程卡管理": "REQ-S0-013",
  73. "物料代替方案管理": "REQ-S0-014",
  74. "物料替代明细管理(制造域)": "REQ-S0-015",
  75. "物料替代明细管理": "REQ-S0-015",
  76. "SOP 文件类型管理": "REQ-S0-016",
  77. "SOP 维护管理": "REQ-S0-017",
  78. "工单控制参数管理": "REQ-S0-018",
  79. "订单排程周期管理": "REQ-S0-019",
  80. "物料管理": "REQ-S0-020",
  81. "客户管理": "REQ-S0-021",
  82. "订单评审周期规则管理": "REQ-S0-023",
  83. "产品设计周期规则管理": "REQ-S0-024",
  84. "合同评审周期规则管理": "REQ-S0-025",
  85. "订单优先级规则管理": "REQ-S0-026",
  86. "供应商管理": "REQ-S0-027",
  87. "采购分类与货源管理": "REQ-S0-028",
  88. "品类交货周期管理": "REQ-S0-029",
  89. "物料计划周期管理": "REQ-S0-030",
  90. "部门管理": "REQ-S0-031",
  91. "员工管理": "REQ-S0-032",
  92. "员工工作职责管理": "REQ-S0-033",
  93. "库位管理": "REQ-S0-034",
  94. "库位货架管理": "REQ-S0-035",
  95. "物料包装规格管理": "REQ-S0-036",
  96. "批次号控制管理": "REQ-S0-037",
  97. "批次类型管理": "REQ-S0-038",
  98. "标签类型管理": "REQ-S0-039",
  99. "成本中心管理": "REQ-S0-040",
  100. "任务分配管理": "REQ-S0-041",
  101. "检验基准管理": "REQ-S0-042",
  102. "检验频次管理": "REQ-S0-043",
  103. "检验仪器管理": "REQ-S0-044",
  104. "检验项目管理": "REQ-S0-045",
  105. "检验方法管理": "REQ-S0-046",
  106. "检验标准管理": "REQ-S0-047",
  107. "抽样方案管理": "REQ-S0-048",
  108. "检验计划管理": "REQ-S0-049",
  109. "来料检验规范(IQC)": "REQ-S0-050",
  110. "过程检验规范(IPQC)": "REQ-S0-051",
  111. "成品检验规范(OQC/FQC)": "REQ-S0-052",
  112. "来料白名单管理": "REQ-S0-053",
  113. "质量字典管理": "REQ-S0-054",
  114. "指标目录管理(KpiMaster)": "REQ-S0-058",
  115. "条码规则管理": "REQ-S0-060",
  116. "计量器具管理": "REQ-S0-061",
  117. "业务事实字典": "REQ-S0-062",
  118. "物料工艺要素管理": "REQ-S0-063",
  119. "详情看板配置": "REQ-S0-064",
  120. }
  121. S0_EXTRA_ALIAS = {
  122. "运营指标主数据": "指标目录管理(KpiMaster)",
  123. "指标关系图谱": "详情看板配置",
  124. "检验方案": "检验计划管理",
  125. "条码规则": "条码规则管理",
  126. "物料工艺要素": "物料工艺要素管理",
  127. }
  128. S5_REQ = {
  129. "来料检验申请查询": "REQ-S5-019",
  130. "采购收货单查询": "REQ-S5-004",
  131. "生产退料单查询": "REQ-S5-006",
  132. "库存查询": "REQ-S5-010",
  133. "进出存查询": "REQ-S5-011",
  134. "标签查询": "REQ-S5-008",
  135. "盘点结果查询与盘点标签确认": "REQ-S5-012",
  136. }
  137. S6_REQ = {
  138. "过程检验单查询与详情": "REQ-S6-002",
  139. "IPQC 检验流转(检验员提交 / 主管审核)": "REQ-S6-002",
  140. "生产设备台账查询": "REQ-S6-003",
  141. "模工具台账查询": "REQ-S6-004",
  142. }
  143. S7_REQ = {
  144. "生产入库单查询": "REQ-S7-003",
  145. "销售发货通知查询": "REQ-S7-004",
  146. "FQC 检验任务查询": "REQ-S7-001",
  147. "FQC 检验结果查询与详情": "REQ-S7-002",
  148. }
  149. S8_REQ = {
  150. "异常监控看板": "REQ-S8-001",
  151. "监控大屏(交付 / 生产 / 供应 / 订单执行)": "REQ-S8-002",
  152. "异常列表与详情": "REQ-S8-008",
  153. "异常处理流转": "REQ-S8-019",
  154. "主动提报": "REQ-S8-009",
  155. "监视规则配置与运行": "REQ-S8-016",
  156. "数据源配置": "REQ-S8-015",
  157. "订单执行链路下钻": "REQ-S8-007",
  158. "配置中心": "REQ-S8-010",
  159. }
  160. SCOPE = {
  161. "S1": (
  162. "本模块覆盖销售订单评审与交期确认、交付跟踪、发货、工单下达、计划联动、需求明细核验及产销协同看板。",
  163. "不维护客户/物料等主数据(见 S0);不编制车间作业计划与报工(见 S2/S6);"
  164. "不处理采购到货与来料检验(见 S4/S5);不处理成品出库实物确认(见 S7)。",
  165. ),
  166. "S2": (
  167. "本模块覆盖生产排程、作业计划及制造协同看板,承接 S1 已下达工单并形成可执行计划。",
  168. "不维护工艺/产线主数据(见 S0);不评审销售订单(见 S1);"
  169. "不负责车间报工与过程检验判定(见 S6);不处理采购与仓储事务(见 S3–S5)。",
  170. ),
  171. "S3": (
  172. "本模块覆盖物料需求与交货计划、采购申请、要货令、采购/委外/外协订单及供应协同、齐套看板。",
  173. "不维护供应商主数据(见 S0);不处理供应商交货回执与到货入库(见 S4/S5);不排产(见 S2)。",
  174. ),
  175. "S4": (
  176. "本模块覆盖供应商交货、发货单、采购退货、IQC 退货查询及采购执行看板。",
  177. "不编制采购订单与要货令(见 S3);不做到货入库记账与 IQC 判定(见 S5);不维护供应商主数据(见 S0)。",
  178. ),
  179. }
  180. JOBS = [
  181. ("S0", "brd", "S0-运营建模-业务需求描述-V0.6.docx", "V0.6", "V0.7"),
  182. ("S0", "bbp", "S0-运营建模-业务蓝图设计方案-V0.6.docx", "V0.6", "V0.7"),
  183. ("S0", "dds", "S0运营建模模块详细设计说明书.docx", "V1.0", "V1.1"),
  184. ("S0", "um", "S0-运营建模-用户操作手册.docx", "V1.0", "V1.1"),
  185. ("S1", "brd", "S1产销协同业务需求描述.docx", "V0.2", "V0.3"),
  186. ("S1", "bbp", "S1产销协同模块蓝图设计方案.docx", "V2.0", "V2.1"),
  187. ("S1", "dds", "S1产销协同模块详细设计说明书.docx", "V1.0", "V1.1"),
  188. ("S1", "um", "S1产销协同模块用户操作手册.docx", "V1.0", "V1.1"),
  189. ("S2", "brd", "S2制造协同业务需求描述.docx", "V0.2", "V0.3"),
  190. ("S2", "bbp", "S2制造协同模块蓝图设计方案.docx", "V0.2", "V0.3"),
  191. ("S2", "dds", "S2制造协同模块详细设计说明书.docx", "V1.0", "V1.1"),
  192. ("S2", "um", "S2制造协同模块用户操作手册.docx", "V1.0", "V1.1"),
  193. ("S3", "brd", "S3供应协同业务需求描述.docx", "V0.2", "V0.3"),
  194. ("S3", "bbp", "S3供应协同模块蓝图设计方案.docx", "V0.2", "V0.3"),
  195. ("S3", "dds", "S3供应协同模块详细设计说明书.docx", "V1.0", "V1.1"),
  196. ("S3", "um", "S3供应协同模块用户操作手册.docx", "V1.0", "V1.1"),
  197. ("S4", "brd", "S4采购执行业务需求描述.docx", "V0.2", "V0.3"),
  198. ("S4", "bbp", "S4采购执行模块蓝图设计方案.docx", "V0.2", "V0.3"),
  199. ("S4", "dds", "S4采购执行模块详细设计说明书.docx", "V1.0", "V1.1"),
  200. ("S4", "um", "S4采购执行模块用户操作手册.docx", "V1.0", "V1.1"),
  201. ("S5", "brd", "S5-物料仓储-业务需求描述.docx", "V1.0", "V1.1"),
  202. ("S5", "bbp", "S5-物料仓储-业务蓝图设计方案.docx", "V0.1", "V0.2"),
  203. ("S5", "dds", "S5物料仓储模块详细设计说明书.docx", "V1.0", "V1.1"),
  204. ("S5", "um", "S5-物料仓储-用户操作手册.docx", "V1.0", "V1.1"),
  205. ("S6", "brd", "S6-生产执行-业务需求描述.docx", "V1.0", "V1.1"),
  206. ("S6", "bbp", "S6-生产执行-业务蓝图设计方案.docx", "V0.1", "V0.2"),
  207. ("S6", "dds", "S6生产执行模块详细设计说明书.docx", "V1.0", "V1.1"),
  208. ("S6", "um", "S6-生产执行-用户操作手册.docx", "V1.0", "V1.1"),
  209. ("S7", "brd", "S7-成品仓储-业务需求描述.docx", "V1.0", "V1.1"),
  210. ("S7", "bbp", "S7-成品仓储-业务蓝图设计方案.docx", "V0.1", "V0.2"),
  211. ("S7", "dds", "S7成品仓储模块详细设计说明书.docx", "V1.0", "V1.1"),
  212. ("S7", "um", "S7-成品仓储-用户操作手册.docx", "V1.0", "V1.1"),
  213. ("S8", "brd", "S8-异常监控-业务需求描述.docx", "V1.0", "V1.1"),
  214. ("S8", "bbp", "S8-异常监控-业务蓝图设计方案.docx", "V0.1", "V0.2"),
  215. ("S8", "dds", "S8异常监控模块详细设计说明书.docx", "V1.0", "V1.1"),
  216. ("S8", "um", "S8-异常监控-用户操作手册.docx", "V1.0", "V1.1"),
  217. ]
  218. def dest_name(src_name: str, new_ver: str) -> str:
  219. stem = Path(src_name).stem
  220. stem = re.sub(r"-V\d+(?:\.\d+)+$", "", stem)
  221. return f"{stem}-{new_ver}.docx"
  222. def apply_repl(text: str, pairs) -> str:
  223. for a, b in pairs:
  224. if a in text:
  225. text = text.replace(a, b)
  226. return text
  227. def iter_paras(doc: Document):
  228. for p in doc.paragraphs:
  229. yield p
  230. for table in doc.tables:
  231. for row in table.rows:
  232. for cell in row.cells:
  233. for p in cell.paragraphs:
  234. yield p
  235. def replace_everywhere(doc: Document) -> int:
  236. n = 0
  237. for p in iter_paras(doc):
  238. old = p.text
  239. new = apply_repl(apply_repl(old, SYS_REPL), DATA_REPL)
  240. if new != old:
  241. set_paragraph_text(p, new)
  242. n += 1
  243. return n
  244. def body_blocks(doc: Document):
  245. for child in doc.element.body:
  246. tag = child.tag.split("}")[-1]
  247. if tag == "p":
  248. yield "p", Paragraph(child, doc)
  249. elif tag == "tbl":
  250. yield "t", Table(child, doc)
  251. def find_p(doc: Document, *names: str) -> Paragraph | None:
  252. want = {n.strip() for n in names}
  253. for p in doc.paragraphs:
  254. if p.text.strip() in want:
  255. return p
  256. return None
  257. def set_cell(cell, text: str, *, bold=False, size=10.5, color=None, fill=None, center=False):
  258. cell.text = ""
  259. p = cell.paragraphs[0]
  260. if center:
  261. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  262. run = p.add_run(text)
  263. run.font.size = Pt(size)
  264. run.bold = bold
  265. run.font.name = "微软雅黑"
  266. r_pr = run._element.get_or_add_rPr()
  267. r_fonts = r_pr.rFonts
  268. if r_fonts is None:
  269. r_fonts = OxmlElement("w:rFonts")
  270. r_pr.insert(0, r_fonts)
  271. r_fonts.set(qn("w:ascii"), "微软雅黑")
  272. r_fonts.set(qn("w:hAnsi"), "微软雅黑")
  273. r_fonts.set(qn("w:eastAsia"), "微软雅黑")
  274. if color is not None:
  275. run.font.color.rgb = color
  276. if fill:
  277. tc_pr = cell._element.get_or_add_tcPr()
  278. old = tc_pr.find(qn("w:shd"))
  279. if old is not None:
  280. tc_pr.remove(old)
  281. shd = OxmlElement("w:shd")
  282. shd.set(qn("w:val"), "clear")
  283. shd.set(qn("w:color"), "auto")
  284. shd.set(qn("w:fill"), fill)
  285. tc_pr.append(shd)
  286. def strip_document_control(doc: Document) -> int:
  287. removed = 0
  288. drop_next_table = False
  289. for kind, obj in list(body_blocks(doc)):
  290. if kind == "p":
  291. t = obj.text.strip()
  292. if t == "文档控制":
  293. remove_paragraph(obj)
  294. removed += 1
  295. continue
  296. if t in {"审核", "发布"}:
  297. remove_paragraph(obj)
  298. drop_next_table = True
  299. removed += 1
  300. continue
  301. if t == "更改记录":
  302. set_paragraph_text(obj, "版本记录")
  303. removed += 1
  304. elif kind == "t" and drop_next_table:
  305. obj._tbl.getparent().remove(obj._tbl)
  306. drop_next_table = False
  307. removed += 1
  308. return removed
  309. def update_cover_version(doc: Document, old_ver: str, new_ver: str) -> int:
  310. n = 0
  311. for table in doc.tables[:4]:
  312. for row in table.rows:
  313. cells = [c.text.strip().replace("\n", "") for c in row.cells]
  314. if not cells:
  315. continue
  316. key = cells[0]
  317. if key in {"版本", "文档版本", "当前版本"} and len(row.cells) >= 2:
  318. set_cell(row.cells[1], new_ver, center=True)
  319. n += 1
  320. for i, c in enumerate(row.cells):
  321. raw = c.text.strip()
  322. if raw == old_ver or raw == f"{old_ver}(草稿)":
  323. set_cell(c, new_ver, center=True)
  324. n += 1
  325. elif raw.startswith(old_ver) and "日期" not in raw and len(raw) < 20:
  326. set_cell(c, raw.replace(old_ver, new_ver, 1), center=True)
  327. n += 1
  328. for p in doc.paragraphs[:20]:
  329. t = p.text
  330. if "当前版本" in t and old_ver in t:
  331. set_paragraph_text(p, t.replace(old_ver, new_ver))
  332. n += 1
  333. elif t.startswith("版本:") and old_ver in t:
  334. set_paragraph_text(p, t.replace(old_ver, new_ver))
  335. n += 1
  336. elif "编制日期" in t or t.startswith("更新日期"):
  337. set_paragraph_text(p, re.sub(r"\d{4}-\d{2}-\d{2}", TODAY, t, count=1))
  338. n += 1
  339. return n
  340. def version_table_after(heading: Paragraph) -> Table | None:
  341. el = heading._element.getnext()
  342. while el is not None:
  343. tag = el.tag.split("}")[-1]
  344. if tag == "tbl":
  345. return Table(el, heading._parent)
  346. if tag == "p":
  347. txt = "".join(el.itertext()).strip()
  348. if txt:
  349. return None
  350. el = el.getnext()
  351. return None
  352. def append_version_row(table: Table, new_ver: str) -> None:
  353. headers = [c.text.strip().replace("\n", "") for c in table.rows[0].cells]
  354. values = {
  355. "日期": TODAY,
  356. "版本": new_ver,
  357. "修订人": AUTHOR,
  358. "姓名": AUTHOR,
  359. "变更说明": NOTE,
  360. }
  361. # reuse last empty data row if present
  362. target = None
  363. if len(table.rows) > 1:
  364. last = table.rows[-1]
  365. if not any(c.text.strip() for c in last.cells):
  366. target = last
  367. if target is None:
  368. target = table.add_row()
  369. for i, h in enumerate(headers):
  370. if i < len(target.cells) and h in values:
  371. set_cell(target.cells[i], values[h], size=10.5)
  372. def _ensure_tbl_borders(table: Table) -> None:
  373. tbl = table._tbl
  374. tbl_pr = tbl.tblPr
  375. if tbl_pr is None:
  376. tbl_pr = OxmlElement("w:tblPr")
  377. tbl.insert(0, tbl_pr)
  378. borders = tbl_pr.find(qn("w:tblBorders"))
  379. if borders is None:
  380. borders = OxmlElement("w:tblBorders")
  381. tbl_pr.append(borders)
  382. for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
  383. el = borders.find(qn(f"w:{edge}"))
  384. if el is None:
  385. el = OxmlElement(f"w:{edge}")
  386. borders.append(el)
  387. el.set(qn("w:val"), "single")
  388. el.set(qn("w:sz"), "4")
  389. el.set(qn("w:space"), "0")
  390. el.set(qn("w:color"), "000000")
  391. def make_version_table(doc: Document, heading: Paragraph, old_ver: str, new_ver: str) -> None:
  392. table = doc.add_table(rows=3, cols=4)
  393. try:
  394. table.style = "Table Grid"
  395. except (KeyError, ValueError):
  396. _ensure_tbl_borders(table)
  397. headers = ["日期", "版本", "修订人", "变更说明"]
  398. for i, h in enumerate(headers):
  399. set_cell(table.rows[0].cells[i], h, bold=True, color=RGBColor(255, 255, 255), fill="1F3864", center=True)
  400. set_cell(table.rows[1].cells[0], "2026-06-10")
  401. set_cell(table.rows[1].cells[1], old_ver)
  402. set_cell(table.rows[1].cells[2], AUTHOR)
  403. set_cell(table.rows[1].cells[3], "既有版本")
  404. set_cell(table.rows[2].cells[0], TODAY)
  405. set_cell(table.rows[2].cells[1], new_ver)
  406. set_cell(table.rows[2].cells[2], AUTHOR)
  407. set_cell(table.rows[2].cells[3], NOTE)
  408. heading._element.addnext(table._tbl)
  409. def ensure_version_page(doc: Document, old_ver: str, new_ver: str) -> str:
  410. heading = find_p(doc, "版本记录", "更改记录")
  411. if heading is not None:
  412. if heading.text.strip() == "更改记录":
  413. set_paragraph_text(heading, "版本记录")
  414. tbl = version_table_after(heading)
  415. if tbl is not None:
  416. append_version_row(tbl, new_ver)
  417. ensure_page_break_before(heading)
  418. toc = find_p(doc, "目录")
  419. if toc is not None:
  420. ensure_page_break_before(toc)
  421. return "append"
  422. toc = find_p(doc, "目录")
  423. if toc is None:
  424. return "skip"
  425. heading = toc.insert_paragraph_before("版本记录")
  426. heading.style = toc.style if toc.style and toc.style.name.startswith("Heading") else "Heading 1"
  427. make_version_table(doc, heading, old_ver, new_ver)
  428. ensure_page_break_before(heading)
  429. ensure_page_break_before(toc)
  430. return "insert"
  431. def s0_req_code(title: str) -> str | None:
  432. if title in S0_CANON_REQ:
  433. return S0_CANON_REQ[title]
  434. if title in S0_EXTRA_ALIAS:
  435. return S0_CANON_REQ.get(S0_EXTRA_ALIAS[title])
  436. if title in S0_ALIASES:
  437. return S0_CANON_REQ.get(S0_ALIASES[title])
  438. return None
  439. def add_req_codes(doc: Document, module: str) -> int:
  440. mapping = {
  441. "S0": None,
  442. "S5": S5_REQ,
  443. "S6": S6_REQ,
  444. "S7": S7_REQ,
  445. "S8": S8_REQ,
  446. }.get(module)
  447. n = 0
  448. for p in doc.paragraphs:
  449. raw = p.text.strip()
  450. if not raw or "REQ-S" in raw:
  451. continue
  452. title = extract_heading_title(raw)
  453. if module == "S8" and re.match(r"^[一二三四五六七八九十]+、", raw):
  454. title = re.sub(r"^[一二三四五六七八九十]+、", "", raw).strip()
  455. if not title:
  456. continue
  457. code = s0_req_code(title) if module == "S0" else (mapping or {}).get(title)
  458. if code and append_code(p, code):
  459. n += 1
  460. return n
  461. def insert_scope(doc: Document, module: str) -> int:
  462. if module not in SCOPE:
  463. return 0
  464. if any("本模块覆盖" in p.text for p in doc.paragraphs[:40]):
  465. return 0
  466. target = None
  467. for p in doc.paragraphs:
  468. if re.match(r"^(一)", p.text.strip()):
  469. target = p
  470. break
  471. if target is None:
  472. return 0
  473. do, dont = SCOPE[module]
  474. p3 = target.insert_paragraph_before(f"本模块不做什么:{dont}")
  475. p2 = target.insert_paragraph_before(f"本模块做什么:{do}")
  476. p1 = target.insert_paragraph_before("范围说明")
  477. try:
  478. p1.style = "Heading 2"
  479. except Exception:
  480. pass
  481. return 3
  482. def insert_login_pointer(doc: Document) -> int:
  483. marker = None
  484. for p in doc.paragraphs:
  485. t = p.text.strip()
  486. if t in {"1 登录与首页", "1. 系统登录与首页"}:
  487. marker = p
  488. break
  489. if marker is None:
  490. return 0
  491. el = marker._element.getnext()
  492. while el is not None and el.tag.split("}")[-1] != "p":
  493. el = el.getnext()
  494. if el is not None and "SYS-用户操作手册-总纲" in "".join(el.itertext()):
  495. return 0
  496. text = (
  497. "登录、修改密码、退出等通用操作见《SYS-用户操作手册-总纲》。"
  498. "本章仅说明进入本模块后的页面位置;详细登录步骤以总纲为准。"
  499. )
  500. if el is not None:
  501. Paragraph(el, marker._parent).insert_paragraph_before(text)
  502. else:
  503. marker.insert_paragraph_before(text)
  504. return 1
  505. def insert_um_common(doc: Document) -> int:
  506. if any("通用操作说明" in p.text for p in doc.paragraphs[:80]):
  507. return 0
  508. target = None
  509. for p in doc.paragraphs:
  510. t = p.text.strip()
  511. if t in {"2. 模块操作说明", "2.模块操作说明"} or re.match(r"^2\.1\s", t):
  512. target = p
  513. break
  514. if target is None:
  515. return 0
  516. lines = [
  517. "在查询区填写条件后点查询筛选数据,点重置清空条件并回到默认结果。",
  518. "列表底部显示总条数,可翻页或切换每页条数;操作列按行提供查看或编辑入口。",
  519. "录入窗口中红色星号为必填项,未填时保存会提示,不会生成不完整单据。",
  520. ]
  521. for line in reversed(lines):
  522. target.insert_paragraph_before(line)
  523. h = target.insert_paragraph_before("2.0 通用操作说明")
  524. try:
  525. h.style = "Heading 2"
  526. except Exception:
  527. pass
  528. return 4
  529. def patch_s1_dds(doc: Document) -> int:
  530. n = 0
  531. # rename appendix 7 -> 9 first
  532. for p in doc.paragraphs:
  533. t = p.text
  534. nt = t
  535. nt = re.sub(r"^7\.3\s", "9.3 ", nt)
  536. nt = re.sub(r"^7\.2\s", "9.2 ", nt)
  537. nt = re.sub(r"^7\.1\s", "9.1 ", nt)
  538. nt = re.sub(r"^7\.\s*附录", "9. 附录", nt)
  539. if nt != t:
  540. set_paragraph_text(p, nt)
  541. n += 1
  542. # renumber 3.2.x via placeholders
  543. mapping = [
  544. ("3.2.8", "@@A@@"),
  545. ("3.2.7", "@@B@@"),
  546. ("3.2.5", "@@C@@"),
  547. ("3.2.4", "@@D@@"),
  548. ("3.2.3", "@@E@@"),
  549. ("3.2.2", "@@F@@"),
  550. ]
  551. final = {
  552. "@@A@@": "3.2.7",
  553. "@@B@@": "3.2.5",
  554. "@@C@@": "3.2.4",
  555. "@@D@@": "3.2.3",
  556. "@@E@@": "3.2.2",
  557. "@@F@@": "3.2.1",
  558. }
  559. for p in iter_paras(doc):
  560. t = p.text
  561. nt = t
  562. for a, b in mapping:
  563. nt = nt.replace(a, b)
  564. for a, b in final.items():
  565. nt = nt.replace(a, b)
  566. if nt != t:
  567. set_paragraph_text(p, nt)
  568. n += 1
  569. # insert 3.2.6 before 3.2.7 产销协同看板
  570. target = None
  571. for p in doc.paragraphs:
  572. if p.text.strip().startswith("3.2.7") and "产销协同看板" in p.text:
  573. target = p
  574. break
  575. if target is not None and not any("3.2.6" in x.text and "需求明细核验" in x.text for x in doc.paragraphs):
  576. blocks = [
  577. ("Heading 3", "3.2.6 需求明细核验(S1-M08)"),
  578. ("Heading 3", "3.2.6.1 业务流程"),
  579. ("Normal", "计划员在交期确认前核验订单需求明细与成品库存、在制及物料齐套是否一致;有差异时先解释差异再确认交期。"),
  580. ("Heading 3", "3.2.6.2 功能处理"),
  581. ("Normal", "核验须绑定订单号与需求行;数量、交期、物料编码为空不得提交。核验结论与系统计算不一致时必须填写原因。无授权不得改写需求数量。"),
  582. ("Heading 3", "3.2.6.3 页面设计"),
  583. ("Normal", "列表按订单号、客户、物料筛选;详情展示需求行、齐套结论与差异说明。"),
  584. ("Heading 3", "3.2.6.4 设计验证要点"),
  585. ("Normal", "缺必填不可保存;差异无原因不可确认;核验结果可在订单评审与计划联动中追溯。"),
  586. ]
  587. for style, text in blocks:
  588. np = target.insert_paragraph_before(text)
  589. try:
  590. np.style = style
  591. except Exception:
  592. pass
  593. n += 1
  594. app = None
  595. for p in doc.paragraphs:
  596. if p.text.strip().startswith("9.") and "附录" in p.text:
  597. app = p
  598. break
  599. if app is not None and not any("SaaS" in x.text for x in doc.paragraphs):
  600. blocks = [
  601. ("Heading 1", "7. SaaS部署与运维设计"),
  602. ("Heading 2", "7.1 部署模式和环境"),
  603. ("Normal", "S1 随 Ai-DOP智慧运营管理系统统一 SaaS 多租户部署,不单独建库;开发、预发、生产环境配置隔离。"),
  604. ("Heading 2", "7.2 部署架构"),
  605. ("Normal", "采用容器化部署:反向代理 + 前端静态资源 + .NET API + MySQL + Redis。S1 业务服务随平台一同发布。"),
  606. ("Heading 2", "7.3 租户开通和初始化"),
  607. ("Normal", "新租户开通后初始化组织、角色与 S1 菜单权限;订单评审等业务数据按租户隔离,禁止跨租户可见。"),
  608. ("Heading 2", "7.4 发布、升级和回滚"),
  609. ("Normal", "发版随平台版本号发布;升级失败按平台回滚策略回退至上一可用版本,保留订单与评审记录。"),
  610. ("Heading 2", "7.5 监控、备份和恢复"),
  611. ("Normal", "监控接口时延、排程/评审任务失败率与同步延迟;数据库按平台策略备份,恢复后核对订单与交期结论。"),
  612. ("Heading 1", "8. 非功能设计"),
  613. ("Normal", "性能:列表默认分页,常用查询应在可接受时间内返回。安全:接口鉴权、操作审计、租户隔离。可用性:关键操作失败须给出业务原因,不得静默丢单。可维护性:需求、蓝图、详设编号与 FUNC/REQ 对齐。"),
  614. ]
  615. for style, text in blocks:
  616. np = app.insert_paragraph_before(text)
  617. try:
  618. np.style = style
  619. except Exception:
  620. pass
  621. n += 1
  622. return n
  623. def ensure_sys_cover(doc: Document) -> int:
  624. early = [p.text for p in doc.paragraphs[:15]]
  625. if any(SYS in t for t in early):
  626. return 0
  627. first = None
  628. for p in doc.paragraphs[:8]:
  629. if p.text.strip():
  630. first = p
  631. break
  632. if first is None:
  633. return 0
  634. el = first._element.getnext()
  635. while el is not None and el.tag.split("}")[-1] != "p":
  636. el = el.getnext()
  637. if el is None:
  638. return 0
  639. np = Paragraph(el, first._parent).insert_paragraph_before(SYS)
  640. np.alignment = WD_ALIGN_PARAGRAPH.CENTER
  641. return 1
  642. def open_via_temp(src: Path) -> tuple[Document, Path]:
  643. TMP.mkdir(parents=True, exist_ok=True)
  644. work = TMP / f"in_{src.stem[:40]}.docx"
  645. shutil.copy2(src, work)
  646. return Document(str(work)), work
  647. def process_job(module: str, kind: str, src_name: str, old_ver: str, new_ver: str) -> str:
  648. src = BASE / module / src_name
  649. if not src.exists():
  650. return f"MISSING {src}"
  651. dest = BASE / module / dest_name(src_name, new_ver)
  652. doc, work = open_via_temp(src)
  653. notes = []
  654. notes.append(f"repl={replace_everywhere(doc)}")
  655. notes.append(f"syscover={ensure_sys_cover(doc)}")
  656. notes.append(f"ctrl={strip_document_control(doc)}")
  657. notes.append(f"cover={update_cover_version(doc, old_ver, new_ver)}")
  658. notes.append(f"verpage={ensure_version_page(doc, old_ver, new_ver)}")
  659. if kind == "brd" and module in {"S0", "S5", "S6", "S7", "S8"}:
  660. notes.append(f"req={add_req_codes(doc, module)}")
  661. if kind == "brd" and module in SCOPE:
  662. notes.append(f"scope={insert_scope(doc, module)}")
  663. if kind == "dds" and module == "S1":
  664. notes.append(f"s1dds={patch_s1_dds(doc)}")
  665. if kind == "um":
  666. notes.append(f"login={insert_login_pointer(doc)}")
  667. if module in {"S1", "S2", "S3", "S4"}:
  668. notes.append(f"common={insert_um_common(doc)}")
  669. out = TMP / f"out_{dest.stem[:40]}.docx"
  670. doc.save(str(out))
  671. shutil.copy2(out, dest)
  672. return f"OK {dest.name} ({'; '.join(notes)}) {dest.stat().st_size // 1024}KB"
  673. def main() -> None:
  674. TMP.mkdir(parents=True, exist_ok=True)
  675. lines = [f"TODAY={TODAY}", ""]
  676. only = None
  677. for arg in sys.argv[1:]:
  678. if arg.startswith("--only="):
  679. only = {x.strip().upper() for x in arg.split("=", 1)[1].split(",") if x.strip()}
  680. for module, kind, src_name, old_ver, new_ver in JOBS:
  681. if only and module not in only:
  682. continue
  683. print(f"processing {module} {kind} {src_name} -> {new_ver}", flush=True)
  684. try:
  685. msg = process_job(module, kind, src_name, old_ver, new_ver)
  686. except Exception as exc:
  687. msg = f"FAIL {module} {src_name}: {exc}"
  688. print(msg, flush=True)
  689. lines.append(msg)
  690. REPORT.write_text("\n".join(lines), encoding="utf-8")
  691. print("report", REPORT)
  692. if __name__ == "__main__":
  693. main()