_fix_delivery_docs.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. # -*- coding: utf-8 -*-
  2. from __future__ import annotations
  3. import re
  4. from pathlib import Path
  5. from docx import Document
  6. from docx.enum.text import WD_ALIGN_PARAGRAPH
  7. from docx.oxml import OxmlElement
  8. from docx.shared import Inches, Pt
  9. from docx.text.paragraph import Paragraph
  10. from PIL import Image, ImageDraw, ImageFont
  11. BASE = Path(r"D:\Projects\Ai-DOP\项目\项目管理\产互联项目管理\交付文档")
  12. MARKER = "【原型页面】"
  13. W, H = 1200, 680
  14. REMOVE_SECTION_STARTS = (
  15. "补充:原型设计",
  16. "补充:系统数据流",
  17. "六、核心业务流程",
  18. "七、业务数据流",
  19. "补充:核心业务流程",
  20. "补充:业务数据流",
  21. )
  22. HEADING_RE = re.compile(r"^(FUNC-S(\d+)-(\d+(?:~\d+)?))(?:\s+(.*))?$")
  23. DOMAIN_MENU = {
  24. "manufacturing": "制造建模",
  25. "sales": "产销建模",
  26. "supply": "供应建模",
  27. "warehouse": "仓储建模",
  28. "quality": "质量建模",
  29. "kanban": "运营指标建模",
  30. }
  31. def load_fonts():
  32. try:
  33. return (
  34. ImageFont.truetype("msyh.ttc", 11),
  35. ImageFont.truetype("msyh.ttc", 12),
  36. ImageFont.truetype("msyh.ttc", 14),
  37. ImageFont.truetype("msyh.ttc", 18),
  38. )
  39. except OSError:
  40. d = ImageFont.load_default()
  41. return d, d, d, d
  42. def delete_paragraph(paragraph: Paragraph):
  43. el = paragraph._element
  44. el.getparent().remove(el)
  45. def paragraph_has_image(paragraph: Paragraph) -> bool:
  46. xml = paragraph._element.xml
  47. return "w:drawing" in xml or "w:pict" in xml or "pic:pic" in xml
  48. def is_func_heading(text: str) -> bool:
  49. text = text.strip()
  50. if re.fullmatch(r"FUNC-S\d+-\d{3}", text):
  51. return False
  52. return bool(HEADING_RE.match(text))
  53. def func_code(text: str) -> str | None:
  54. m = HEADING_RE.match(text.strip())
  55. return m.group(1) if m else None
  56. def clean_document(path: Path) -> int:
  57. doc = Document(path)
  58. removed = 0
  59. i = 0
  60. while i < len(doc.paragraphs):
  61. p = doc.paragraphs[i]
  62. text = p.text.strip()
  63. if any(text.startswith(s) for s in REMOVE_SECTION_STARTS):
  64. delete_paragraph(p)
  65. removed += 1
  66. while i < len(doc.paragraphs):
  67. t = doc.paragraphs[i].text.strip()
  68. if not t or re.match(r"^\d+\.", t):
  69. delete_paragraph(doc.paragraphs[i])
  70. removed += 1
  71. continue
  72. break
  73. continue
  74. if text == MARKER:
  75. delete_paragraph(p)
  76. removed += 1
  77. if i < len(doc.paragraphs) and paragraph_has_image(doc.paragraphs[i]):
  78. delete_paragraph(doc.paragraphs[i])
  79. removed += 1
  80. if i < len(doc.paragraphs):
  81. t = doc.paragraphs[i].text.strip()
  82. if t.startswith("图:"):
  83. delete_paragraph(doc.paragraphs[i])
  84. removed += 1
  85. continue
  86. if text.startswith("图:") and "页面原型" in text:
  87. delete_paragraph(p)
  88. removed += 1
  89. continue
  90. i += 1
  91. doc.save(path)
  92. return removed
  93. def insert_after(paragraph: Paragraph, text: str = "") -> Paragraph:
  94. new_p = OxmlElement("w:p")
  95. paragraph._p.addnext(new_p)
  96. new_para = Paragraph(new_p, paragraph._parent)
  97. if text:
  98. new_para.add_run(text)
  99. return new_para
  100. def rr(draw, xy, radius=6, **kw):
  101. draw.rounded_rectangle(xy, radius=radius, **kw)
  102. def draw_text(draw, xy, value, font, fill="#444444"):
  103. draw.text(xy, value, fill=fill, font=font)
  104. def infer_domain(page_path: str) -> str:
  105. page_path = page_path.lower()
  106. if "quality" in page_path:
  107. return "quality"
  108. if "kanban" in page_path:
  109. return "kanban"
  110. for key in ("manufacturing", "sales", "supply", "warehouse"):
  111. if key in page_path:
  112. return key
  113. return "manufacturing"
  114. def infer_headers(desc: str) -> list[str]:
  115. patterns = [
  116. r"列表字段包括([^。]+)",
  117. r"字段包括([^。]+)",
  118. r"字段含([^。,]+)",
  119. r"列表字段为:([^。]+)",
  120. r"指标字段:([^。]+)",
  121. ]
  122. for pat in patterns:
  123. m = re.search(pat, desc)
  124. if not m:
  125. continue
  126. parts = re.split(r"[、,,/]", re.sub(r"\([^)]*\)", "", m.group(1)))
  127. headers = [p.strip() for p in parts if p.strip() and len(p.strip()) <= 12][:6]
  128. if headers:
  129. if "操作" not in headers:
  130. headers.append("操作")
  131. return headers
  132. if "路线" in desc and "工序" in desc:
  133. return ["路线编码", "名称", "适用物料", "版本", "状态", "操作"]
  134. return ["编码", "名称", "状态", "更新时间", "操作"]
  135. def infer_filters(desc: str) -> list[str]:
  136. m = re.search(r"支持按([^筛选]+)筛选", desc)
  137. if m:
  138. out = [p.strip() for p in re.split(r"[、/]", m.group(1)) if p.strip()][:4]
  139. if out:
  140. return out
  141. if "产线" in desc and "员工" in desc:
  142. return ["产线", "员工"]
  143. return ["关键字", "公司", "工厂"]
  144. def infer_add_label(title: str) -> str:
  145. return "新增" + title.replace("管理", "") if "管理" in title else "新增"
  146. def infer_form_fields(title: str) -> list[str]:
  147. if "工单控制参数" in title:
  148. return ["默认批次规则", "下达前置时间", "其他控制参数"]
  149. return ["参数项", "参数值", "说明"]
  150. def render_crud_png(path, *, module, menu, title, subtitle, filters, headers, add_label, row):
  151. xs, s, m, b = load_fonts()
  152. img = Image.new("RGB", (W, H), "#fafafa")
  153. draw = ImageDraw.Draw(img)
  154. draw.rectangle([0, 0, W, 44], fill="#ffffff", outline="#d9d9d9")
  155. draw_text(draw, (190, 14), f"Ai-DOP / {module}", s, "#444444")
  156. draw.rectangle([0, 44, 170, H], fill="#1f2937")
  157. draw_text(draw, (16, 72), module, s, "#ffffff")
  158. draw_text(draw, (32, 98), menu, xs, "#cbd5e1")
  159. draw.rectangle([170, 44, W, H], fill="#f3f4f6")
  160. rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
  161. draw_text(draw, (210, 72), title[:18], b, "#ffffff")
  162. draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
  163. x = 210
  164. for label in filters[:4]:
  165. draw_text(draw, (x, 132), label[:8], xs, "#444444")
  166. rr(draw, [x, 148, x + 110, 174], radius=4, outline="#d9d9d9", fill="#ffffff")
  167. x += 130
  168. bx = 210
  169. for label, color in [("查询", "#1677ff"), ("重置", "#ffffff"), (add_label[:8], "#52c41a")]:
  170. w = max(64, len(label) * 14 + 24)
  171. rr(draw, [bx, 188, bx + w, 216], radius=4, fill=color, outline=color if color != "#ffffff" else "#d9d9d9")
  172. draw_text(draw, (bx + 10, 194), label, s, "#ffffff" if color != "#ffffff" else "#444444")
  173. bx += w + 10
  174. rr(draw, [190, 232, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
  175. draw.rectangle([190, 232, W - 20, 266], fill="#fafafa", outline="#d9d9d9")
  176. col_w = max(80, (W - 230) // max(len(headers), 1))
  177. for i, h in enumerate(headers):
  178. draw_text(draw, (200 + i * col_w, 242), h[:8], xs, "#333333")
  179. if row:
  180. draw.line([190, 276, W - 20, 276], fill="#f0f0f0", width=1)
  181. for i, cell in enumerate(row[: len(headers)]):
  182. draw_text(draw, (200 + i * col_w, 286), str(cell)[:10], xs, "#666666")
  183. path.parent.mkdir(parents=True, exist_ok=True)
  184. img.save(path, format="PNG")
  185. def render_form_png(path, *, module, menu, title, subtitle, fields):
  186. xs, s, m, b = load_fonts()
  187. img = Image.new("RGB", (W, H), "#fafafa")
  188. draw = ImageDraw.Draw(img)
  189. draw.rectangle([170, 44, W, H], fill="#f3f4f6")
  190. rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
  191. draw_text(draw, (210, 72), title, b, "#ffffff")
  192. draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
  193. rr(draw, [190, 132, W - 20, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9")
  194. y = 150
  195. for label in fields:
  196. draw_text(draw, (210, y), label, xs, "#444444")
  197. rr(draw, [210, y + 16, 520, y + 42], radius=4, outline="#d9d9d9", fill="#ffffff")
  198. y += 56
  199. rr(draw, [210, H - 58, 274, H - 30], radius=4, fill="#1677ff")
  200. draw_text(draw, (226, H - 52), "保存", s, "#ffffff")
  201. path.parent.mkdir(parents=True, exist_ok=True)
  202. img.save(path, format="PNG")
  203. def render_kpi_graph_png(path, title, subtitle):
  204. xs, s, m, b = load_fonts()
  205. img = Image.new("RGB", (W, H), "#fafafa")
  206. draw = ImageDraw.Draw(img)
  207. rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
  208. draw_text(draw, (210, 72), title, b, "#ffffff")
  209. draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
  210. rr(draw, [190, 132, W - 340, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
  211. draw_text(draw, (210, 148), "L1/L2 指标关系图谱", s, "#333333")
  212. rr(draw, [W - 310, 132, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
  213. draw_text(draw, (W - 290, 148), "节点编辑抽屉", s, "#333333")
  214. img.save(path, format="PNG")
  215. def render_kpi_module_png(path, title, subtitle):
  216. xs, s, m, b = load_fonts()
  217. img = Image.new("RGB", (W, H), "#fafafa")
  218. draw = ImageDraw.Draw(img)
  219. rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827")
  220. draw_text(draw, (210, 72), title, b, "#ffffff")
  221. draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1")
  222. rr(draw, [190, 132, 520, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
  223. draw_text(draw, (210, 148), "L1/L2 指标勾选", s, "#333333")
  224. rr(draw, [540, 132, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9")
  225. draw_text(draw, (560, 148), "布局预览", s, "#333333")
  226. img.save(path, format="PNG")
  227. def parse_s0_funcs(doc: Document) -> dict[str, dict]:
  228. funcs: dict[str, dict] = {}
  229. current = None
  230. desc_parts: list[str] = []
  231. for p in doc.paragraphs:
  232. text = p.text.strip()
  233. if is_func_heading(text):
  234. if current:
  235. funcs[current]["desc"] = "".join(desc_parts)
  236. m = HEADING_RE.match(text)
  237. current = m.group(1)
  238. funcs[current] = {
  239. "title": (m.group(4) or "").strip() or current,
  240. "desc": "",
  241. "req": "",
  242. "page": "",
  243. }
  244. desc_parts = []
  245. continue
  246. if not current:
  247. continue
  248. if text.startswith("**对应需求**:"):
  249. funcs[current]["req"] = text.split(":", 1)[1].strip()
  250. elif text.startswith("**前端页面**:") or text.startswith("**前端实现**:"):
  251. funcs[current]["page"] = text.split(":", 1)[1].strip(" `")
  252. elif text.startswith("**功能描述**:"):
  253. continue
  254. elif text.startswith("FUNC-") or text.startswith("补充:") or text.startswith("【") or text.startswith("图:"):
  255. continue
  256. elif text.startswith("**") or text in {"功能编号", "功能名称", "页面文件"}:
  257. continue
  258. elif text:
  259. desc_parts.append(text)
  260. if current:
  261. funcs[current]["desc"] = "".join(desc_parts)
  262. return funcs
  263. def build_s0_png(code: str, info: dict) -> Path:
  264. title = info["title"]
  265. desc = info["desc"]
  266. req = info.get("req") or code.replace("FUNC", "REQ")
  267. page = info.get("page", "")
  268. domain = infer_domain(page)
  269. menu = DOMAIN_MENU.get(domain, "数据建模")
  270. subtitle = f"{req} · {page}" if page else req
  271. out = BASE / "S0" / "prototype" / f"{code}.png"
  272. if "表单形式" in desc or code == "FUNC-S0-018":
  273. render_form_png(out, module="S0 运营建模", menu=menu, title=title, subtitle=subtitle, fields=infer_form_fields(title))
  274. return out
  275. if code in ("FUNC-S0-055", "FUNC-S0-056"):
  276. render_kpi_graph_png(out, title, subtitle)
  277. return out
  278. if code == "FUNC-S0-058":
  279. render_crud_png(
  280. out,
  281. module="S0 运营建模",
  282. menu=menu,
  283. title=title,
  284. subtitle=subtitle,
  285. filters=["模块", "层级", "关键字"],
  286. headers=["指标编码", "显示名称", "模块", "层级", "启用", "操作"],
  287. add_label="新增指标",
  288. row=["S1_L1_001", "订单评审周期", "S1", "L1", "是", "编辑"],
  289. )
  290. return out
  291. if code in ("FUNC-S0-057", "FUNC-S0-059"):
  292. render_kpi_module_png(out, title, subtitle)
  293. return out
  294. headers = infer_headers(desc)
  295. filters = infer_filters(desc)
  296. row = []
  297. for h in headers:
  298. if h == "操作":
  299. row.append("编辑")
  300. elif "编码" in h or h.endswith("号"):
  301. row.append("001")
  302. elif "名称" in h or "描述" in h:
  303. row.append("示例")
  304. elif "状态" in h or "启用" in h or "确认" in h:
  305. row.append("是")
  306. else:
  307. row.append("-")
  308. render_crud_png(
  309. out,
  310. module="S0 运营建模",
  311. menu=menu,
  312. title=title,
  313. subtitle=subtitle,
  314. filters=filters,
  315. headers=headers,
  316. add_label=infer_add_label(title),
  317. row=row,
  318. )
  319. return out
  320. def insert_prototypes(doc_path: Path, mapping: dict[str, Path]):
  321. doc = Document(doc_path)
  322. headings = [(i, func_code(p.text)) for i, p in enumerate(doc.paragraphs) if is_func_heading(p.text)]
  323. inserts = []
  324. for idx, (start, code) in enumerate(headings):
  325. if not code or code not in mapping:
  326. continue
  327. end = headings[idx + 1][0] if idx + 1 < len(headings) else len(doc.paragraphs)
  328. inserts.append((end - 1, code, mapping[code]))
  329. for insert_at, code, png in reversed(inserts):
  330. anchor = doc.paragraphs[insert_at]
  331. cap = insert_after(anchor, MARKER)
  332. cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
  333. pic_p = insert_after(cap)
  334. pic_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  335. pic_p.add_run().add_picture(str(png), width=Inches(6.2))
  336. note = insert_after(pic_p, f"图:{code}")
  337. note.alignment = WD_ALIGN_PARAGRAPH.CENTER
  338. for r in note.runs:
  339. r.font.size = Pt(9)
  340. doc.save(doc_path)
  341. def main():
  342. for mod in ["S0", "S1", "S2"]:
  343. for kind in ["功能设计说明", "需求说明书"]:
  344. n = clean_document(BASE / mod / f"{mod}-{kind}.docx")
  345. print(f"cleaned {mod}-{kind}: {n}")
  346. s0_doc = Document(BASE / "S0" / "S0-功能设计说明.docx")
  347. funcs = parse_s0_funcs(s0_doc)
  348. mapping = {code: build_s0_png(code, info) for code, info in funcs.items()}
  349. insert_prototypes(BASE / "S0" / "S0-功能设计说明.docx", mapping)
  350. print(f"S0 prototypes: {len(mapping)}")
  351. from _insert_func_prototypes import build_templates, process_doc
  352. templates = build_templates(BASE / "_prototype_templates")
  353. for mod in ["S1", "S2"]:
  354. process_doc(mod, templates)
  355. if __name__ == "__main__":
  356. main()