_export_quote_doc_compare_excel.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. # -*- coding: utf-8 -*-
  2. """Export S0-S2 quote vs requirement/blueprint comparison to Excel."""
  3. from __future__ import annotations
  4. from pathlib import Path
  5. import openpyxl
  6. from docx import Document
  7. from openpyxl.styles import Alignment, Font, PatternFill
  8. from openpyxl.utils import get_column_letter
  9. QUOTE = Path(
  10. r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目报价"
  11. r"\AI-DOP数字化运营平台开发报价清单_工时内容补充_增加算法_20260630.xlsx"
  12. )
  13. DELIVERY = Path(
  14. r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目管理\产互联项目管理\交付文档"
  15. )
  16. OUT_XLSX = Path(__file__).resolve().parent / "plan" / "S0-S2-报价与交付文档对比表.xlsx"
  17. DOCS = {
  18. "S0": (
  19. DELIVERY / "S0" / "S0-基础主数据-业务需求描述-V0.2.docx",
  20. DELIVERY / "S0" / "S0-基础主数据建模-业务蓝图设计方案-V0.2.docx",
  21. ),
  22. "S1": (
  23. DELIVERY / "S1" / "S1产销协同业务需求描述.docx",
  24. DELIVERY / "S1" / "S1产销协同模块蓝图设计方案.docx",
  25. ),
  26. "S2": (
  27. DELIVERY / "S2" / "S2制造协同业务需求描述.docx",
  28. DELIVERY / "S2" / "S2制造协同模块蓝图设计方案.docx",
  29. ),
  30. }
  31. MOD_QUOTE_NAME = {"S0": "运营建模", "S1": "产销协同", "S2": "制造协同"}
  32. HEADERS = [
  33. "模块",
  34. "报价需求模块",
  35. "报价需求项",
  36. "需求项描述",
  37. "需求文档",
  38. "蓝图文档",
  39. "说明",
  40. ]
  41. def load_quote_corpus() -> dict:
  42. """Load verbatim quote text from 汇总 / 明细 / 算法清单."""
  43. wb = openpyxl.load_workbook(QUOTE, data_only=True)
  44. corpus: dict = {"summary": {}, "detail": {}, "algo": {}, "kanban": {}}
  45. ws = wb["报价汇总-.NET方案"]
  46. for mod, name in MOD_QUOTE_NAME.items():
  47. for r in range(1, ws.max_row + 1):
  48. if ws.cell(r, 3).value == name:
  49. corpus["summary"][mod] = {
  50. "front": str(ws.cell(r, 4).value or ""),
  51. "front_days": ws.cell(r, 5).value,
  52. "back": str(ws.cell(r + 1, 4).value or ""),
  53. "db": str(ws.cell(r + 2, 4).value or ""),
  54. }
  55. break
  56. ws = wb["报价明细-.NET方案"]
  57. blueprint_map = {
  58. "S1": "产销协同蓝图设计",
  59. "S2": "制造协同蓝图设计",
  60. }
  61. for mod, label in blueprint_map.items():
  62. for r in range(1, ws.max_row + 1):
  63. if ws.cell(r, 2).value == label:
  64. corpus["detail"][f"{mod}_blueprint"] = (
  65. f"项目准备及业务蓝图设计 / {label}:"
  66. f"开发人数 {ws.cell(r, 4).value},"
  67. f"开发天数 {ws.cell(r, 5).value},"
  68. f"预估人天 {ws.cell(r, 6).value}"
  69. )
  70. break
  71. detail_blocks = {
  72. "S0": "运营建模",
  73. "S1": "产销协同",
  74. "S2": "制造协同",
  75. }
  76. for mod, block in detail_blocks.items():
  77. rows = []
  78. in_block = False
  79. for r in range(1, ws.max_row + 1):
  80. c2, c3 = ws.cell(r, 2).value, ws.cell(r, 3).value
  81. if c2 == block:
  82. in_block = True
  83. if in_block and c2 and c2 != block and c2 in detail_blocks.values() and c2 != block:
  84. break
  85. if in_block and c3 and c3 != "单项小计":
  86. rows.append(
  87. f"功能开发和业务实现 / {block} / {c3}:"
  88. f"开发人数 {ws.cell(r, 4).value},"
  89. f"开发天数 {ws.cell(r, 5).value},"
  90. f"预估人天 {ws.cell(r, 6).value}"
  91. )
  92. corpus["detail"][mod] = rows
  93. for r in range(1, ws.max_row + 1):
  94. if ws.cell(r, 2).value == "九宫格智慧运营看板":
  95. mod_name = ws.cell(r, 3).value
  96. for mod, qn in MOD_QUOTE_NAME.items():
  97. if qn == mod_name:
  98. corpus["kanban"][mod] = (
  99. f"九宫格智慧运营看板 / {mod_name}:"
  100. f"开发人数 {ws.cell(r, 4).value},"
  101. f"开发天数 {ws.cell(r, 5).value},"
  102. f"预估人天 {ws.cell(r, 6).value}"
  103. )
  104. ws = wb["算法清单"]
  105. for r in range(3, ws.max_row + 1):
  106. mod_name = ws.cell(r, 2).value
  107. algo_text = str(ws.cell(r, 3).value or "")
  108. for mod, qn in MOD_QUOTE_NAME.items():
  109. if qn == mod_name:
  110. corpus["algo"][mod] = algo_text
  111. wb.close()
  112. return corpus
  113. def load_doc_text(path: Path) -> str:
  114. doc = Document(path)
  115. parts = [p.text for p in doc.paragraphs]
  116. for table in doc.tables:
  117. for row in table.rows:
  118. for cell in row.cells:
  119. parts.append(cell.text)
  120. return "\n".join(parts)
  121. def match_status(text: str, keywords: list[str], *, required: list[str] | None = None) -> str:
  122. if not keywords:
  123. return "—"
  124. hits = [k for k in keywords if k in text]
  125. if required and all(r in text for r in required):
  126. return "✅"
  127. if required and any(r in text for r in required):
  128. return "⚠️"
  129. if len(hits) == len(keywords):
  130. return "✅"
  131. if hits:
  132. return "⚠️"
  133. return "❌"
  134. def split_quote_segments(line: str, sep: str) -> list[str]:
  135. if ":" in line:
  136. line = line.split(":", 1)[1]
  137. return [s.strip() for s in line.split(sep) if s.strip()]
  138. def find_segment(full: str, keyword: str, sep: str = "、") -> str:
  139. for seg in split_quote_segments(full, sep):
  140. if keyword in seg or seg.startswith(keyword):
  141. return seg
  142. return full
  143. def make_row(
  144. module: str,
  145. quote_module: str,
  146. quote_item: str,
  147. item_desc: str,
  148. req_doc: str,
  149. blue_doc: str,
  150. note: str,
  151. ) -> dict:
  152. if (
  153. "🔴" not in note
  154. and req_doc not in ("—", "✅")
  155. and (req_doc == "❌" or blue_doc == "❌")
  156. ):
  157. note = f"🔴 报价已列此项,交付文档未覆盖或未写全。{note}" if note else "🔴 报价已列此项,交付文档未覆盖或未写全。"
  158. return {
  159. "模块": module,
  160. "报价需求模块": quote_module,
  161. "报价需求项": quote_item,
  162. "需求项描述": item_desc,
  163. "需求文档": req_doc,
  164. "蓝图文档": blue_doc,
  165. "说明": note,
  166. }
  167. def build_s0(req_t: str, blue_t: str, q: dict) -> list[dict]:
  168. m = "S0"
  169. s = q["summary"][m]
  170. rows: list[dict] = []
  171. front_items = [
  172. (
  173. "主数据维护(物料/客户/供应商/人员组织)",
  174. "主数据维护页面",
  175. ["物料", "客户", "供应商"],
  176. ["员工", "组织", "人员"],
  177. "需求文档按列表逐项描述物料/客户/供应商等;蓝图第2–6章按域覆盖。",
  178. ),
  179. (
  180. "BOM与工艺路线维护",
  181. "BOM与工艺路线维护",
  182. ["BOM", "工艺路线"],
  183. ["标准 BOM", "工艺"],
  184. "需求含标准BOM、工艺路线、工序等;蓝图「制造主数据」章有对应方案。",
  185. ),
  186. (
  187. "仓储库位与条码规则",
  188. "仓储库位与条码规则",
  189. ["库位", "条码", "批次"],
  190. ["仓储", "货架"],
  191. "需求含库位、批次、条码规则;蓝图「仓储主数据」章有对应内容。",
  192. ),
  193. (
  194. "质量检验标准配置",
  195. "质量检验标准配置",
  196. ["检验标准", "检验项目", "抽样"],
  197. ["IQC", "质量"],
  198. "需求含检验项目/标准/抽样;蓝图「质量主数据」章有检验基准类描述。",
  199. ),
  200. (
  201. "指标目录与业务口径配置",
  202. "指标目录与业务口径配置",
  203. ["指标"],
  204. ["业务口径", "指标目录", "口径定义"],
  205. "需求文档有指标/KPI相关条目的覆盖不完整;蓝图未单独设指标/口径章节。",
  206. ),
  207. (
  208. "指标图谱浏览",
  209. "指标图谱浏览",
  210. ["指标"],
  211. ["图谱", "指标图谱"],
  212. "报价前端单独列出指标图谱;需求仅有指标类描述,蓝图无「图谱浏览」专章。",
  213. ),
  214. ]
  215. for item, key, rkw, bkw, note in front_items:
  216. desc = find_segment(s["front"], key)
  217. if s["front_days"]:
  218. desc = f"{desc}(模块前端合计 {s['front_days']} 人天;原文:{s['front']})"
  219. rows.append(
  220. make_row(
  221. m,
  222. "【汇总·前端】运营建模",
  223. item,
  224. desc,
  225. match_status(req_t, rkw, required=rkw[:2] if len(rkw) >= 2 else rkw),
  226. match_status(blue_t, bkw if bkw else rkw),
  227. note,
  228. )
  229. )
  230. for seg in split_quote_segments(s["back"], "、"):
  231. rows.append(
  232. make_row(
  233. m,
  234. "【汇总·后端】运营建模",
  235. seg,
  236. f"后端:{seg}(原文:{s['back']})",
  237. match_status(req_t, [seg[:4]] if len(seg) >= 4 else [seg[:2]]),
  238. match_status(blue_t, [seg[:4]] if len(seg) >= 4 else [seg[:2]]),
  239. "后端能力在需求列表条目中有分散体现;蓝图以域方案描述为主,未逐条复现报价原文。",
  240. )
  241. )
  242. for seg in split_quote_segments(s["db"], ","):
  243. rows.append(
  244. make_row(
  245. m,
  246. "【汇总·数据库】运营建模",
  247. seg,
  248. f"数据库:{seg}(原文:{s['db']})",
  249. match_status(req_t, [w for w in ["物料", "BOM", "库位", "检验", "指标", "参数"] if w in seg]),
  250. match_status(blue_t, [w for w in ["物料", "BOM", "库位", "检验", "指标", "参数"] if w in seg]),
  251. "数据对象在需求条目中分散出现;蓝图未单独设「数据库设计」节,按域方案间接覆盖。",
  252. )
  253. )
  254. detail_rows = q["detail"].get(m, [])
  255. for i, (item, note) in enumerate(
  256. [
  257. ("数据建模", "报价明细工时包;对应需求文档大量列表型主数据条目。"),
  258. ("业务建模", "报价明细工时包;对应蓝图 Word 按销售/供应/制造/质量/仓储五域组织。"),
  259. ]
  260. ):
  261. desc = detail_rows[i] if i < len(detail_rows) else item
  262. rows.append(
  263. make_row(
  264. m,
  265. "【明细】功能开发",
  266. item,
  267. desc,
  268. "—",
  269. "✅",
  270. note,
  271. )
  272. )
  273. rows.append(
  274. make_row(
  275. m,
  276. "【算法清单】",
  277. "(本模块无单独算法行)",
  278. "算法清单 sheet 自「产销协同」起列出模块算法;运营建模未单列算法条目。",
  279. "—",
  280. "—",
  281. "不属于报有他无;本版报价未对 S0 承诺独立算法清单项。",
  282. )
  283. )
  284. return rows
  285. def build_s1(req_t: str, blue_t: str, q: dict) -> list[dict]:
  286. m = "S1"
  287. s = q["summary"][m]
  288. algo_full = q["algo"].get(m, "")
  289. rows: list[dict] = []
  290. front_specs = [
  291. ("订单评审", True, "(一)订单评审管理 REQ-S1-003", "蓝图第2章 订单评审管理",
  292. "报价前端与需求(一)、蓝图第2章一致,可逐项验收。"),
  293. ("合同评审", False, None, None,
  294. "报价汇总前端明确列出;需求文档无(一)之前章节,无 REQ-S1-002 专章;蓝图无 FUNC-S1-002 章。"),
  295. ("产品设计", False, None, None,
  296. "报价汇总前端明确列出;需求/蓝图均无 REQ-S1-001 / FUNC-S1-001 对应章节。"),
  297. ("订单交付", True, "(二)订单交付管理 REQ-S1-004", "蓝图第3章",
  298. "报价「订单交付」与需求(二)、蓝图第3章一致。"),
  299. ("发货", True, "(三)订单发货管理 REQ-S1-005", "蓝图第4章",
  300. "报价「发货」与需求(三)、蓝图第4章一致。"),
  301. ("工单下达", True, "(四)工单下达 REQ-S1-006", "蓝图第5章",
  302. "报价与需求(四)、蓝图第5章一致。"),
  303. ("计划联动看板", True, "(五)计划联动看板 REQ-S1-007", "蓝图第6章",
  304. "报价与需求(五)、蓝图第6章一致。"),
  305. ("产销协同看板", True, "(七)产销协同看板 REQ-S1-009", "蓝图第8章",
  306. "报价与需求(七)、蓝图第8章一致。"),
  307. ]
  308. for key, ok, req_ref, blue_ref, note in front_specs:
  309. seg = find_segment(s["front"], key, "/")
  310. desc = f"{seg}(模块前端合计 {s['front_days']} 人天;原文:{s['front']})"
  311. rows.append(
  312. make_row(
  313. m,
  314. "【汇总·前端】产销协同",
  315. key,
  316. desc,
  317. "✅" if ok else "❌",
  318. "✅" if ok else "❌",
  319. note if not ok else f"{note} 对照位置:需求{req_ref};{blue_ref}。",
  320. )
  321. )
  322. for seg in split_quote_segments(s["back"], "、"):
  323. kw_map = {
  324. "销售订单": ["订单", "变更"],
  325. "合同": ["评审", "合同"],
  326. "交期": ["交期", "齐套"],
  327. "发货计划": ["发货", "ASN"],
  328. "工单下达": ["工单下达"],
  329. "订单交付进度": ["交付", "跟踪"],
  330. "发货达成": ["发货", "异常"],
  331. "看板指标": ["看板", "指标"],
  332. }
  333. kw = next((v for k, v in kw_map.items() if k in seg), [seg[:2]])
  334. extra = ""
  335. if "设计评审" in seg or "合同" in seg:
  336. extra = "其中合同评审、设计评审在 Word 中缺独立章节。"
  337. elif "产品设计与评审资料" in seg:
  338. extra = ""
  339. rows.append(
  340. make_row(
  341. m,
  342. "【汇总·后端】产销协同",
  343. seg,
  344. f"后端:{seg}(原文:{s['back']})",
  345. match_status(req_t, kw),
  346. match_status(blue_t, kw),
  347. f"报价后端能力项;需求/蓝图在相关功能章有分散描述。{extra}".strip(),
  348. )
  349. )
  350. for seg in split_quote_segments(s["db"], ","):
  351. kw = [w for w in ["订单", "合同", "产品设计", "发货", "工单", "交付", "缺料", "看板"] if w in seg]
  352. note = ""
  353. if "产品设计" in seg:
  354. note = "报价数据库对象含产品设计资料;需求/蓝图均无产品设计专章,无法直接对照。"
  355. rows.append(
  356. make_row(
  357. m,
  358. "【汇总·数据库】产销协同",
  359. seg,
  360. f"数据库:{seg}(原文:{s['db']})",
  361. match_status(req_t, kw),
  362. match_status(blue_t, kw),
  363. note or "报价数据域在需求/蓝图相关章节中间接体现。",
  364. )
  365. )
  366. detail_specs = [
  367. ("产销协同蓝图设计", q["detail"].get("S1_blueprint", ""), "—", "✅",
  368. "报价明细 11 人天蓝图设计;交付件为《S1产销协同模块蓝图设计方案》Word 全文。"),
  369. ("订单管理", None, "⚠️", "⚠️",
  370. "报价 2 人天打包项,覆盖订单评审/合同/产品设计/交付/发货等;Word 缺 001/002 章,003–005 有。"),
  371. ("工单管理", None, "✅", "✅",
  372. "报价 2 人天;与需求(四)工单下达、蓝图第5章一致。"),
  373. ("计划管理", None, "⚠️", "⚠️",
  374. "报价 2 人天;对应计划联动看板+需求明细核验;后者在报价汇总前端未单独点名。"),
  375. ("产销协同看板", None, "✅", "✅",
  376. "报价 1 人天;与需求(七)、蓝图第8章一致。"),
  377. ]
  378. detail_lines = q["detail"].get(m, [])
  379. for i, (key, _, rs, bs, note) in enumerate(detail_specs):
  380. desc = detail_lines[i] if i < len(detail_lines) else key
  381. if key == "产销协同蓝图设计":
  382. desc = q["detail"].get("S1_blueprint", desc)
  383. rows.append(
  384. make_row(m, "【明细】", key, desc, rs, bs, note)
  385. )
  386. if q["kanban"].get(m):
  387. rows.append(
  388. make_row(
  389. m,
  390. "【明细】九宫格智慧运营看板",
  391. "产销协同",
  392. q["kanban"][m],
  393. "❌",
  394. "❌",
  395. "报价另列 2 人天九宫格产销看板;需求/蓝图仅模块内产销协同看板,未说明与九宫格条目关系。",
  396. )
  397. )
  398. for algo in split_quote_segments(algo_full, "、"):
  399. kw_map = {
  400. "订单资源检查": (["交期", "齐套", "库存"], "003 订单评审"),
  401. "供需缺口": (["缺料", "差额", "满足"], "008 需求明细核验"),
  402. "安全库存": ([], None),
  403. "订单交期模拟": (["交期", "预计交付"], "003 订单评审"),
  404. }
  405. kw, ref = [], None
  406. for k, (w, r) in kw_map.items():
  407. if k in algo:
  408. kw, ref = w, r
  409. break
  410. rs = match_status(req_t, kw) if kw else "❌"
  411. bs = match_status(blue_t, kw) if kw else "❌"
  412. ref_note = f"业务上最接近 {ref}。" if ref else "需求/蓝图全文无相关表述。"
  413. rows.append(
  414. make_row(
  415. m,
  416. "【算法清单】产销协同",
  417. algo,
  418. f"{algo}(模块算法原文:{algo_full})",
  419. rs,
  420. bs,
  421. f"报价算法清单列名;Word 未出现该算法名称,{ref_note} 验收时需补算法说明或改报价。",
  422. )
  423. )
  424. rows.append(
  425. make_row(
  426. m,
  427. "(他有报无·供参考)",
  428. "需求明细核验",
  429. "未出现在报价汇总前端 8 项中;明细「计划管理」可能覆盖。",
  430. "✅",
  431. "✅",
  432. "需求(六)/蓝图第7章有专章;报价汇总前端未单独列出,属文档有而报价汇总未点名。",
  433. )
  434. )
  435. return rows
  436. def build_s2(req_t: str, blue_t: str, q: dict) -> list[dict]:
  437. m = "S2"
  438. s = q["summary"][m]
  439. algo_full = q["algo"].get(m, "")
  440. rows: list[dict] = []
  441. front_specs = [
  442. ("生产排程", ["生产排程", "REQ-S2-001"], ["生产排程", "FUNC-S2-001"],
  443. "报价与需求(一)、蓝图第2章一致。"),
  444. ("可执行日计划", ["日计划", "REQ-S2-003"], ["作业计划", "FUNC-S2-003"],
  445. "报价「可执行日计划」对应需求(二)作业计划、蓝图第3章。"),
  446. ("产线日历与休息加班", ["产线日历", "休息", "加班", "节假日"],
  447. ["REQ-S2-004", "REQ-S2-005"],
  448. "报价合并表述;需求有日历/休息/节假日/加班多条(004–007);蓝图合并在作业计划章,未逐条分章。"),
  449. ("工单进度看板", ["工单", "进度", "REQ-S2-008"], ["进度"],
  450. "报价「工单进度看板」;需求 REQ-S2-008 有;蓝图无独立章标题,合并在看板章。"),
  451. ("制造协同看板", ["制造协同看板", "REQ-S2-009"], ["FUNC-S2-009"],
  452. "报价与需求(三)看板、蓝图第4章一致。"),
  453. ]
  454. for key, rkw, bkw, note in front_specs:
  455. seg = find_segment(s["front"], key.split("与")[0], "/")
  456. if key not in seg and key == "产线日历与休息加班":
  457. seg = "产线日历与休息加班"
  458. desc = f"{seg}(模块前端合计 {s['front_days']} 人天;原文:{s['front']})"
  459. rows.append(
  460. make_row(
  461. m,
  462. "【汇总·前端】制造协同",
  463. key,
  464. desc,
  465. match_status(req_t, rkw, required=rkw[:1]),
  466. match_status(blue_t, bkw, required=bkw[:1]),
  467. note,
  468. )
  469. )
  470. for seg in split_quote_segments(s["back"], "、"):
  471. note = ""
  472. if "重排" in seg or "冲突" in seg:
  473. note = "需求 REQ-S2-002 排产异常有覆盖;「冲突识别/动态重排」表述与报价原文不完全一致。"
  474. rows.append(
  475. make_row(
  476. m,
  477. "【汇总·后端】制造协同",
  478. seg,
  479. f"后端:{seg}(原文:{s['back']})",
  480. match_status(req_t, [w for w in ["排程", "日计划", "日历", "异常", "进度", "看板"] if w in seg][:2] or [seg[:2]]),
  481. match_status(blue_t, [w for w in ["排程", "日计划", "日历", "异常", "进度", "看板"] if w in seg][:2] or [seg[:2]]),
  482. note or "报价后端项;需求/蓝图在排程、日计划、看板章有分散描述。",
  483. )
  484. )
  485. for seg in split_quote_segments(s["db"], ","):
  486. rows.append(
  487. make_row(
  488. m,
  489. "【汇总·数据库】制造协同",
  490. seg,
  491. f"数据库:{seg}(原文:{s['db']})",
  492. match_status(req_t, [w for w in ["排程", "日计划", "日历", "进度", "异常", "工艺", "看板"] if w in seg]),
  493. match_status(blue_t, [w for w in ["排程", "日计划", "日历", "进度", "异常", "工艺", "看板"] if w in seg]),
  494. "报价数据域;需求/蓝图未单独设库表清单,在方案描述中间接体现。",
  495. )
  496. )
  497. detail_specs = [
  498. ("制造协同蓝图设计", "—", "✅",
  499. "报价明细 11 人天;交付件为《S2制造协同模块蓝图设计方案》Word。"),
  500. ("制造需求管理", "❌", "❌",
  501. "报价明细 2 人天独立行;需求/蓝图均无「制造需求管理」专章或同名能力描述。"),
  502. ("生产排程", "✅", "✅",
  503. "报价 2 人天;与需求(一)、蓝图第2章一致。"),
  504. ("作业计划", "✅", "✅",
  505. "报价 2 人天;与需求(二)及日历/休息/节假日/加班条目、蓝图第3章一致。"),
  506. ("制造协同看板", "✅", "✅",
  507. "报价 1 人天;与需求(三)、蓝图第4章一致。"),
  508. ]
  509. detail_lines = q["detail"].get(m, [])
  510. for i, (key, rs, bs, note) in enumerate(detail_specs):
  511. if key == "制造协同蓝图设计":
  512. desc = q["detail"].get("S2_blueprint", detail_lines[0] if detail_lines else key)
  513. else:
  514. idx = i - 1 if i > 0 else 0
  515. desc = detail_lines[idx] if idx < len(detail_lines) else key
  516. rows.append(make_row(m, "【明细】", key, desc, rs, bs, note))
  517. if q["kanban"].get(m):
  518. rows.append(
  519. make_row(
  520. m,
  521. "【明细】九宫格智慧运营看板",
  522. "制造协同",
  523. q["kanban"][m],
  524. "❌",
  525. "❌",
  526. "报价另列 2 人天;需求/蓝图仅有模块制造协同看板,未说明与九宫格条目对应关系。",
  527. )
  528. )
  529. for algo in split_quote_segments(algo_full, "、"):
  530. kw_map = {
  531. "齐套校验": (["齐套"], "排程/工单相关描述"),
  532. "多维约束排程": (["排程", "约束", "产能"], "生产排程章"),
  533. "扰动重排": (["排产异常", "异常"], "REQ-S2-002"),
  534. }
  535. kw, ref = [], ""
  536. for k, (w, r) in kw_map.items():
  537. if k in algo:
  538. kw, ref = w, r
  539. break
  540. rows.append(
  541. make_row(
  542. m,
  543. "【算法清单】制造协同",
  544. algo,
  545. f"{algo}(模块算法原文:{algo_full})",
  546. match_status(req_t, kw),
  547. match_status(blue_t, kw),
  548. f"报价算法清单列名;Word 未写算法名称,相关业务最接近 {ref or '未找到'}。",
  549. )
  550. )
  551. rows.append(
  552. make_row(
  553. m,
  554. "(文档扩展项)",
  555. "排产异常记录 REQ-S2-002",
  556. "未在报价汇总/算法清单中单列条目;需求文档有 REQ-S2-002 专条。",
  557. "✅",
  558. "⚠️",
  559. "需求有排产异常记录;蓝图无独立 FUNC-S2-002 章标题,内容可能在排程章中。",
  560. )
  561. )
  562. return rows
  563. def style_sheet(ws) -> None:
  564. header_fill = PatternFill("solid", fgColor="1F3864")
  565. header_font = Font(color="FFFFFF", bold=True)
  566. for col, h in enumerate(HEADERS, 1):
  567. cell = ws.cell(1, col, h)
  568. cell.fill = header_fill
  569. cell.font = header_font
  570. cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
  571. ws.freeze_panes = "A2"
  572. widths = [6, 20, 28, 52, 10, 10, 56]
  573. for i, w in enumerate(widths, 1):
  574. ws.column_dimensions[get_column_letter(i)].width = w
  575. for row_cells in ws.iter_rows(min_row=2, max_row=ws.max_row):
  576. for cell in row_cells:
  577. cell.alignment = Alignment(vertical="top", wrap_text=True)
  578. req_v = str(row_cells[4].value or "")
  579. blue_v = str(row_cells[5].value or "")
  580. note_v = str(row_cells[6].value or "")
  581. if "🔴" in note_v or ((req_v == "❌" or blue_v == "❌") and req_v != "—"):
  582. for cell in row_cells:
  583. cell.fill = PatternFill("solid", fgColor="FCE4D6")
  584. def write_excel(all_rows: list[dict]) -> None:
  585. OUT_XLSX.parent.mkdir(parents=True, exist_ok=True)
  586. wb = openpyxl.Workbook()
  587. wb.remove(wb.active)
  588. by_mod: dict[str, list[dict]] = {"S0": [], "S1": [], "S2": []}
  589. for r in all_rows:
  590. by_mod[r["模块"]].append(r)
  591. for mod in ["S0", "S1", "S2"]:
  592. ws = wb.create_sheet(mod)
  593. for c, h in enumerate(HEADERS, 1):
  594. ws.cell(1, c, h)
  595. for ri, data in enumerate(by_mod[mod], 2):
  596. for c, h in enumerate(HEADERS, 1):
  597. ws.cell(ri, c, data[h])
  598. style_sheet(ws)
  599. ws_all = wb.create_sheet("全部", 0)
  600. for c, h in enumerate(HEADERS, 1):
  601. ws_all.cell(1, c, h)
  602. for ri, data in enumerate(all_rows, 2):
  603. for c, h in enumerate(HEADERS, 1):
  604. ws_all.cell(ri, c, data[h])
  605. style_sheet(ws_all)
  606. info = wb.create_sheet("说明")
  607. info["A1"] = "对比说明"
  608. lines = [
  609. f"报价单:{QUOTE.name}",
  610. "生成日期:2026-06-22",
  611. "列「需求项描述」:摘自报价单原文(汇总/明细/算法清单),含工时或完整句式。",
  612. "列「需求文档」「蓝图文档」:✅ 有对应章节或内容 ⚠️ 部分有 ❌ 无 — 不适用",
  613. "浅橙行:报价已列但交付文档未覆盖(说明列含 🔴)或双 ❌",
  614. "S0:…业务需求描述-V0.2.docx / …业务蓝图设计方案-V0.2.docx",
  615. "S1/S2:各模块业务需求描述.docx + 模块蓝图设计方案.docx",
  616. ]
  617. for i, line in enumerate(lines, 2):
  618. info.cell(i, 1, line)
  619. wb.save(OUT_XLSX)
  620. def main() -> None:
  621. corpus = load_quote_corpus()
  622. all_rows: list[dict] = []
  623. for mod, (req_p, blue_p) in DOCS.items():
  624. req_t = load_doc_text(req_p)
  625. blue_t = load_doc_text(blue_p)
  626. if mod == "S0":
  627. all_rows.extend(build_s0(req_t, blue_t, corpus))
  628. elif mod == "S1":
  629. all_rows.extend(build_s1(req_t, blue_t, corpus))
  630. else:
  631. all_rows.extend(build_s2(req_t, blue_t, corpus))
  632. write_excel(all_rows)
  633. print(f"Wrote {len(all_rows)} rows -> {OUT_XLSX}")
  634. if __name__ == "__main__":
  635. main()