_gen_seal_list.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. # -*- coding: utf-8 -*-
  2. """校验生成文件未改动原文,并通过 Word COM 取签章位真实页码,生成签章位置清单。"""
  3. import json
  4. import os
  5. import re
  6. import sys
  7. import time
  8. sys.stdout.reconfigure(encoding="utf-8", errors="replace")
  9. from docx import Document
  10. from docx.oxml.ns import qn
  11. DOC_DIR = os.path.dirname(os.path.abspath(__file__))
  12. SRC = os.path.join(DOC_DIR, "_tmp_xunbi.docx")
  13. OUT_NAME = "响应文件-2026年河南产互联制造业数据智能运营平台研发项目-北京智造易科技有限公司.docx"
  14. OUT = os.path.join(DOC_DIR, OUT_NAME)
  15. # 页码要按实际装订的总册算:承诺书、软著一览表、团队表与技术方案各章已并入其中。
  16. # 总册在 OneDrive 里会被手工补材料(业绩扫描件、查询截图等),页码随之变化,
  17. # 因此优先取两处中较新的那一份,并在输出里注明用的是哪份。
  18. FINAL_NAME = "响应文件(完整版)-2026年河南产互联制造业数据智能运营平台研发项目.docx"
  19. LIST_NAME = "响应文件签章位置清单.docx"
  20. LIST_PATH = os.path.join(DOC_DIR, LIST_NAME)
  21. ONEDRIVE_DIR = r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目招投标"
  22. def pick_final():
  23. """在本地生成版与 OneDrive 手工修改版之间取较新的一份;可用 argv 直接指定。"""
  24. if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
  25. return sys.argv[1], "命令行指定"
  26. cands = [(os.path.join(DOC_DIR, FINAL_NAME), "本地生成版"),
  27. (os.path.join(ONEDRIVE_DIR, FINAL_NAME), "OneDrive 修改版")]
  28. cands = [(p, w) for p, w in cands if os.path.exists(p)]
  29. if not cands:
  30. return None, None
  31. return max(cands, key=lambda x: os.path.getmtime(x[0]))
  32. FINAL, FINAL_FROM = pick_final()
  33. # ---------------------------------------------------------------- 1) 原文校验
  34. src = Document(SRC)
  35. def ptext(el):
  36. return "".join(n.text or "" for n in el.iter(qn("w:t")))
  37. ch = list(src.element.body.iterchildren())
  38. c6 = [i for i, e in enumerate(ch)
  39. if e.tag == qn("w:p") and ptext(e).strip().startswith("第六章")
  40. and "响应文件格式" in ptext(e)]
  41. c7 = [i for i, e in enumerate(ch)
  42. if e.tag == qn("w:p") and ptext(e).strip().startswith("第七章")]
  43. s = c6[-1]
  44. t = next(i for i in c7 if i > s)
  45. orig_paras = [ptext(e) for e in ch[s:t] if e.tag == qn("w:p")]
  46. gen = Document(OUT)
  47. gen_paras = [p.text for p in gen.paragraphs]
  48. # 原文中作为「填空提示」的固定文字,填写后必然改变,属预期
  49. FILLED_KEYS = [
  50. "项目编号:", "供应商:", "日 期:", "致:", "1、根据已收到的项目编号为",
  51. "4、一旦我公司成交", "单位名称:", "单位性质:", "地 址:", "成立时间:",
  52. "经营期限:", "姓 名:", "系", "法定代表人:", "本人", "供 应 商:",
  53. "法定代表人身份证号码:", "委托代理人:", "委托代理人身份证号码:",
  54. "供应商名称(盖章):", "日期:", "承诺单位(签章):", "年 月 日",
  55. "对第四章", "对第五章", "供应商名称:", "(采购单位)", "为营造公开",
  56. "八、本人作为", "项目", " 年 月 日",
  57. ]
  58. # 原文第 768 段的手工目录条目(无页码),已整体换成自动目录域,属预期删除
  59. MANUAL_TOC = {"响应函及响应函附录", "法定代表人身份证明书", "法定代表人授权委托书",
  60. "资格审查资料", "商务响应文件", "技术响应文件", "其他"}
  61. # 原文漏排编号、生成时补齐前缀的段落,比对时按「原文是生成文的后缀」放行
  62. PREFIXED = {"包括但不限于以下资料(根据评分办法要求格式自拟):"}
  63. # 原文编号重复、生成时改号的段落(原文 → 生成文)
  64. RENUMBERED = {"(一)技术要求的汇总应答": "(二)技术要求的汇总应答"}
  65. def is_fill_para(txt):
  66. return any(k and txt.strip().startswith(k) for k in FILLED_KEYS)
  67. def norm(txt):
  68. """比对时忽略空白差异:制表符与连续空格在原文/生成文件中写法不一,
  69. 仅因此报「原文缺失」属误报(如封面署名行原文用空格、模板里是制表符)。"""
  70. return re.sub(r"\s+", "", txt)
  71. # 已按填写要求填入日期的段落,原文的空日期行必然消失,属预期
  72. DATE_LINE = re.compile(r"^年+\s*月+\s*日$")
  73. missing = []
  74. gen_set = {norm(x) for x in gen_paras}
  75. for op in orig_paras:
  76. o = op.strip()
  77. if not o or is_fill_para(o) or DATE_LINE.match(norm(o)) or o in MANUAL_TOC:
  78. continue
  79. if o in PREFIXED and any(x.endswith(norm(o)) for x in gen_set):
  80. continue
  81. if o in RENUMBERED and norm(RENUMBERED[o]) in gen_set:
  82. continue
  83. if norm(o) not in gen_set:
  84. missing.append(o)
  85. print("=" * 66)
  86. print("【原文完整性校验】")
  87. print(f" 第六章原文非空段落:{len([x for x in orig_paras if x.strip()])}")
  88. print(f" 生成文件段落总数:{len(gen_paras)}")
  89. if missing:
  90. print(f" !以下 {len(missing)} 条原文未在生成文件中找到:")
  91. for m in missing[:20]:
  92. print(f" - {m[:70]}")
  93. else:
  94. print(" OK:所有未涉及填写的原文段落均原样保留")
  95. # 检查是否混入了非原文的新增条款(响应函应仍为 6 条)
  96. # 按「(一)响应函」到「(二)响应函附录」之间的编号段落计数;
  97. # 早先用「含我公司」筛选会漏掉以「我们」「贵方」开头的第 5、6 条,造成误报。
  98. def _section(paras, start_key, end_key):
  99. try:
  100. a = next(i for i, x in enumerate(paras) if start_key in x)
  101. b = next(i for i, x in enumerate(paras) if i > a and end_key in x)
  102. except StopIteration:
  103. return paras
  104. return paras[a:b]
  105. resp_items = [x for x in _section(gen_paras, "(一)响应函", "(二)响应函附录")
  106. if re.match(r"^[1-9]、", x.strip())]
  107. print(f" 响应函条目数:{len(resp_items)}(原文为 6 条)")
  108. for x in resp_items:
  109. print(f" {x.strip()[:40]}")
  110. # ---------------------------------------------------------------- 2) 取页码
  111. with open(os.path.join(DOC_DIR, "_tmp_seals.json"), encoding="utf-8") as f:
  112. seals = json.load(f)
  113. print(f"\n【页码依据】{FINAL_FROM}:{os.path.basename(FINAL or OUT)}")
  114. if FINAL:
  115. print(" 修改时间 " + time.strftime(
  116. "%Y-%m-%d %H:%M", time.localtime(os.path.getmtime(FINAL))))
  117. pages = {}
  118. try:
  119. import win32com.client as win32
  120. word = win32.DispatchEx("Word.Application")
  121. word.Visible = False
  122. word.DisplayAlerts = 0
  123. d = word.Documents.Open(FINAL or OUT, ReadOnly=True)
  124. d.Repaginate()
  125. total = d.ComputeStatistics(2) # wdStatisticPages
  126. for key, *_ in seals:
  127. try:
  128. pages[key] = d.Bookmarks(key).Range.Information(3)
  129. except Exception as e:
  130. pages[key] = f"?({e.__class__.__name__})"
  131. d.Close(False)
  132. word.Quit()
  133. print(f"\n【页码提取】文档总页数:{total}")
  134. except Exception as e:
  135. total = "?"
  136. print(f"\n[Word COM 不可用] {type(e).__name__}: {e}")
  137. seals.sort(key=lambda s: (pages.get(s[0], 999) if isinstance(pages.get(s[0]), int) else 999,
  138. s[0]))
  139. for key, section, *_ in seals:
  140. print(f" {key} 第 {pages.get(key, '?')} 页 {section}")
  141. # ---------------------------------------------------------------- 3) 生成清单
  142. from docx.enum.table import WD_TABLE_ALIGNMENT
  143. from docx.enum.text import WD_ALIGN_PARAGRAPH
  144. from docx.shared import Cm, Pt
  145. lst = Document()
  146. sec = lst.sections[0]
  147. sec.page_width, sec.page_height = Cm(29.7), Cm(21.0) # A4 横向
  148. sec.orientation = 1
  149. sec.left_margin = sec.right_margin = Cm(1.8)
  150. sec.top_margin = sec.bottom_margin = Cm(1.8)
  151. st = lst.styles["Normal"]
  152. st.font.name = "Times New Roman"
  153. st.font.size = Pt(10.5)
  154. st.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  155. h = lst.add_paragraph()
  156. h.alignment = WD_ALIGN_PARAGRAPH.CENTER
  157. r = h.add_run("响应文件 签章位置清单")
  158. r.font.size = Pt(18)
  159. r.font.bold = True
  160. r._element.get_or_add_rPr().find(qn("w:rFonts")).set(qn("w:eastAsia"), "黑体") \
  161. if r._element.get_or_add_rPr().find(qn("w:rFonts")) is not None else None
  162. sub = lst.add_paragraph()
  163. sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
  164. rs = sub.add_run(f"{FINAL_NAME if FINAL else OUT_NAME}"
  165. f" | 共 {total} 页 | "
  166. f"下列 {len(seals)} 处均为询比文件明文要求,非明文要求者未列入")
  167. rs.font.size = Pt(9)
  168. lst.add_paragraph()
  169. tb = lst.add_table(rows=1, cols=6)
  170. tb.style = "Table Grid"
  171. tb.alignment = WD_TABLE_ALIGNMENT.CENTER
  172. hdrs = ["序号", "页码", "所在章节 / 位置", "盖章要求", "签字要求", "依据出处(询比文件原文)"]
  173. widths = [1.1, 1.8, 5.4, 3.9, 5.4, 8.5]
  174. from docx.oxml import OxmlElement
  175. tb.autofit = False
  176. _lay = OxmlElement("w:tblLayout")
  177. _lay.set(qn("w:type"), "fixed")
  178. tb._tbl.tblPr.append(_lay)
  179. for i, htxt in enumerate(hdrs):
  180. c = tb.rows[0].cells[i]
  181. c.text = ""
  182. rr = c.paragraphs[0].add_run(htxt)
  183. rr.font.bold = True
  184. rr.font.size = Pt(10)
  185. for n, (key, section, stamp, sign, basis) in enumerate(seals, 1):
  186. cells = tb.add_row().cells
  187. for i, v in enumerate([str(n), f"第 {pages.get(key, '?')} 页", section,
  188. stamp, sign, basis]):
  189. cells[i].text = ""
  190. rr = cells[i].paragraphs[0].add_run(v)
  191. rr.font.size = Pt(9.5)
  192. cells[0].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
  193. cells[1].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
  194. for row in tb.rows:
  195. for i, w in enumerate(widths):
  196. row.cells[i].width = Cm(w)
  197. lst.add_paragraph()
  198. p2 = lst.add_paragraph()
  199. r2 = p2.add_run("另:明文要求「加盖单位公章」的复印件(共 2 处)")
  200. r2.font.bold = True
  201. r2.font.size = Pt(11)
  202. for txt in [
  203. "1. 法定代表人身份证复印件 —— 第六章「二、法定代表人身份证明书」末尾原文:"
  204. "附:法定代表人身份证复印件,加盖单位公章。",
  205. "2. 委托代理人身份证复印件 —— 第六章「三、法定代表人授权委托书」末尾原文:"
  206. "附:委托代理人身份证复印件,加盖单位公章。",
  207. ]:
  208. pp = lst.add_paragraph()
  209. pp.paragraph_format.left_indent = Pt(18)
  210. pp.add_run(txt).font.size = Pt(10)
  211. lst.save(LIST_PATH)
  212. print(f"\n[完成] {LIST_PATH}")
  213. import shutil
  214. if os.path.isdir(ONEDRIVE_DIR):
  215. shutil.copy2(LIST_PATH, os.path.join(ONEDRIVE_DIR, LIST_NAME))
  216. print(f"[已同步] {os.path.join(ONEDRIVE_DIR, LIST_NAME)}")