finalize-industry-policy-confirmation.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. """Produce a local confirmation letter and empty return form without sending it."""
  2. from datetime import datetime, timezone
  3. import hashlib
  4. import json
  5. from pathlib import Path
  6. import re
  7. import zipfile
  8. from docx import Document
  9. from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
  10. from docx.enum.text import WD_ALIGN_PARAGRAPH
  11. from docx.oxml import OxmlElement
  12. from docx.oxml.ns import qn
  13. from docx.shared import Cm, Pt, RGBColor
  14. ROOT = Path(__file__).resolve().parents[1]
  15. OUT = ROOT/'artifacts/ksf-industry-policy-confirmation-letter-20261108-r2/package'
  16. WORK = ROOT/'work/industry-policy-confirmation-letter-20261108-r2'
  17. SOURCE = Path('D:/下载/WXWork/1688857450794210/Cache/File/2026-11/ksf-industry-policy-confirmation-letter-20261108-r1.zip')
  18. PRIOR = ROOT/'artifacts/ksf-industry-policy-ranking-review-20261108-r1/package'
  19. def sha(raw):
  20. return hashlib.sha256(raw).hexdigest()
  21. def font(run, size=10.5, bold=False):
  22. run.font.name = 'Microsoft YaHei'
  23. run.font.size = Pt(size)
  24. run.font.bold = bold
  25. run.font.color.rgb = RGBColor(0, 0, 0)
  26. run._element.get_or_add_rPr().get_or_add_rFonts().set(qn('w:eastAsia'), 'Microsoft YaHei')
  27. return run
  28. def para(doc, text, size=10.5, bold=False, keep=False, style=None):
  29. p = doc.add_paragraph(style=style)
  30. font(p.add_run(text), size, bold)
  31. p.paragraph_format.space_after = Pt(5)
  32. p.paragraph_format.line_spacing = 1.18
  33. p.paragraph_format.keep_with_next = keep
  34. return p
  35. def heading(doc, text):
  36. p = para(doc, text, 13, True, True, 'Heading 1')
  37. p.paragraph_format.space_before = Pt(12)
  38. p.paragraph_format.space_after = Pt(7)
  39. return p
  40. def save_json(path, value):
  41. path.write_text(json.dumps(value, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
  42. def main():
  43. assert not OUT.exists(), 'OUTPUT_ALREADY_EXISTS'
  44. raw = SOURCE.read_bytes()
  45. assert len(raw) == 43330 and sha(raw) == 'a0ffb740b17e4bd85baf3db82f1d726f8c5a054a52c5738eaee4bb7ce675fc89'
  46. with zipfile.ZipFile(SOURCE) as z:
  47. entries = {name: digest for digest, name in
  48. (line.split(' ', 1) for line in z.read('FILES.sha256').decode().splitlines() if line)}
  49. assert len(entries) == 3 and set(z.namelist()) == set(entries) | {'FILES.sha256'}
  50. assert len(z.namelist()) == len(set(z.namelist()))
  51. for name, digest in entries.items():
  52. assert '/' not in name and '\\' not in name and sha(z.read(name)) == digest
  53. incoming_md = z.read('原件补充与榜单来源确认单.md')
  54. source_docx = z.read('原件补充与榜单来源确认单.docx')
  55. original = (PRIOR/'原件补充与榜单来源确认单.md').read_bytes()
  56. norm = lambda b: b.decode('utf-8-sig').replace('\r\n', '\n')
  57. assert norm(original) == norm(incoming_md)
  58. with zipfile.ZipFile(WORK/'received/原件补充与榜单来源确认单.docx') as z:
  59. docx_text = z.read('word/document.xml').decode('utf-8')
  60. assert 'nhc-' not in docx_text
  61. review = json.loads((PRIOR/'policy-gap-review.json').read_bytes())
  62. policies = sorted(review['missing'], key=lambda r: (-r['blockedClauses'], r['documentId']))
  63. assert len(policies) == 12 and sum(r['blockedClauses'] for r in policies) == 19
  64. questions = re.findall(r'^([1-5])\. (.+)$', norm(original), re.M)
  65. assert [n for n, _ in questions] == list('12345')
  66. intro = '请政策资料方补充下列12份政策正文及相关附件,请VOC账号方或其他合法数据来源方回复商品榜来源5个问题。本单用于确认原件、授权范围和后续验证条件;先提供已有资料及书面说明,收费采集继续停止。'
  67. policy_intro = '已有9份PDF及4份官方HTML共13份已核原件无需重复提供。请补齐以下文件,确认实际所附的批准清单、适用范围、用量限制、标准文本或征求意见稿;确无附件时请保留原页面链接结构,不按标题推定附件内容。'
  68. priority = '数字标签公告与2026年第6号标准公告各对应5条待核,可优先补齐;这是原件核查顺序,不是机会排序。GB2762-2025标准PDF已持有,其发布公告与相关缺失附件仍需补充。'
  69. evidence = '每份请附来源URL、取得时间(含时区)、标题和文号、正文及附件原始文件、文件字节数和SHA-256。普通浏览器可正常访问时可保存网页;遇到挑战保留失败记录。'
  70. screenshots = '仅能提供截图时,请同时附原始URL和取得时间(含时区);截图按辅助证据单列,不计入完整正文原件数。浏览器另存页按正文完整性、来源身份及附件保留情况核验,可列为网页存档候选,不因保存方式直接降级或自动核验通过。所有新件先标待核,不自动计入13份已核原件。'
  71. versions = '新文件与库内expected SHA不同时,先区分HTML正文、归档包装、PDF和版本,再核发布身份、正文与附件;不覆盖旧常量,不仅凭同标题认定同文件。补件到位后逐条回读19条待核记录,核实适用条件、具体用法和边界。'
  72. ranking_intro = '需要方便面或面类真实商品榜。优先确认一个可接通的平台与榜单(京东、天猫或其他合法授权平台),分别说明Top100和Top400可达范围。请回复以下5问,并提供已有样例或字段合同,不发起收费测试。'
  73. unsupported = '如回复不支持,请提供注明日期、提供方和具体能力范围的书面结论,留作缺项依据;未公开授权能力无法确认时请写未知。后续来源或能力变化另记新观察,不将一次答复扩大为永久或全平台结论。'
  74. boundary = '公开OpenAPI只证明当前文档未声明该服务,不能代表全部商业授权能力。搜索排序、店铺排序、详情徽标及社媒热搜不能替代完整商品榜。来源、权限、完整范围及报价明确前,不生成榜单预算或发出采集请求。首期通过后再确认第二期同口径验证。'
  75. form_note = '以下均为待回填空表,不表示资料已经收到或能力已经确认。政策状态填写已收待核、未收或不适用;榜单问题填写已回复待核、未回复或不适用。不适用须说明依据;提供方结论可为支持、不支持或未知。收到文件不等于核验通过,书面能力答复也不等于已取得榜单。'
  76. file_note = '文件或答复栏请填文件名或书面答复位置;每个文件分别登记实际字节数、完整64位SHA-256及取得时间(含时区)。同项多个文件可另续行,正文与附件分开登记。没有文件时不编造字节数或SHA;写不适用并记录书面答复日期和依据。'
  77. next_note = '政策原件及附件核验后再关闭相应待核条款。榜单先验收一项真实来源的完整首期,再依同口径安排第二期。目录、价格、详情、评价补采及持续监听仍按逐商品计划和明确预算推进;本确认单不代表全量主线完成。'
  78. md = ['# 原件补充与榜单来源确认单', '', '版本 v2 2026年11月8日', '', intro, '',
  79. '## 政策原件补充', '', policy_intro, '',
  80. '| 编号 | 文件ID | 完整标题与来源 | 待核条款数 |', '| --- | --- | --- | ---: |']
  81. for i, r in enumerate(policies, 1):
  82. md.append(f"| P{i:02} | {r['documentId']} | [{r['title']}]({r['sourceUrl']}) | {r['blockedClauses']} |")
  83. md += ['', priority, '', evidence, '', screenshots, '', versions, '', '## 商品榜来源确认', '', ranking_intro, '']
  84. md += [f'{n}. {text}' for n, text in questions]
  85. md += ['', unsupported, '', boundary, '', '## 收件后验证', '', next_note, '', '## 逐项回执', '', form_note, '', file_note, '',
  86. '| 关联项 | 收件或答复状态 | 文件名或书面答复 | 字节数 | SHA-256 | 取得时间含时区 |',
  87. '| --- | --- | --- | ---: | --- | --- |']
  88. receipt_rows = []
  89. for i, r in enumerate(policies, 1):
  90. key = f'P{i:02}'
  91. receipt_rows.append(dict(itemId=key, kind='policy_original', documentId=r['documentId'],
  92. title=r['title'], sourceUrl=r['sourceUrl'], receiptStatus='PENDING_REPLY',
  93. verificationStatus='NOT_REVIEWED', evidenceFiles=[], notApplicableReason=None))
  94. for n, text in questions:
  95. receipt_rows.append(dict(itemId=f'Q{int(n):02}', kind='ranking_question', question=text,
  96. receiptStatus='PENDING_REPLY', providerConclusion=None,
  97. writtenReply=None, repliedAt=None, evidenceFiles=[],
  98. verificationStatus='NOT_REVIEWED', notApplicableReason=None))
  99. md += [f"| {r['itemId']} | 待回填 | | | | |" for r in receipt_rows]
  100. md += ['', '附件文件字段:fileName、itemId、sourceUrl、bytes、sha256、obtainedAt(含时区)、evidenceType、verificationStatus。多个附件可另附文件清单。', '']
  101. doc = Document()
  102. section = doc.sections[0]
  103. section.page_width, section.page_height = Cm(21), Cm(29.7)
  104. section.top_margin, section.bottom_margin = Cm(1.8), Cm(1.8)
  105. section.left_margin, section.right_margin = Cm(1.8), Cm(1.8)
  106. section.header_distance, section.footer_distance = Cm(0.8), Cm(0.8)
  107. normal = doc.styles['Normal']
  108. normal.font.name, normal.font.size = 'Microsoft YaHei', Pt(10.5)
  109. normal.element.get_or_add_rPr().get_or_add_rFonts().set(qn('w:eastAsia'), 'Microsoft YaHei')
  110. for name in ('Title', 'Heading 1', 'Heading 2'):
  111. doc.styles[name].font.color.rgb = RGBColor(0, 0, 0)
  112. for style in doc.styles:
  113. for border in style.element.xpath('.//w:pBdr'):
  114. border.getparent().remove(border)
  115. para(doc, '原件补充与榜单来源确认单', 19, True, True, 'Title')
  116. para(doc, '版本 v2 2026年11月8日', 9)
  117. para(doc, intro)
  118. heading(doc, '政策原件补充')
  119. para(doc, policy_intro)
  120. for i, r in enumerate(policies, 1):
  121. para(doc, f"P{i:02} {r['title']}", 10.5, True, True)
  122. p = para(doc, f"文件ID {r['documentId']} 待核条款 {r['blockedClauses']} 条", 8.5, keep=True)
  123. p.paragraph_format.space_after = Pt(2)
  124. p = para(doc, r['sourceUrl'], 8.5)
  125. p.paragraph_format.space_after = Pt(7)
  126. for text in (priority, evidence, screenshots, versions):
  127. para(doc, text)
  128. heading(doc, '商品榜来源确认')
  129. para(doc, ranking_intro)
  130. for n, text in questions:
  131. para(doc, n+' '+text)
  132. for text in (unsupported, boundary):
  133. para(doc, text)
  134. heading(doc, '收件后验证')
  135. para(doc, next_note, 10)
  136. doc.add_page_break()
  137. heading(doc, '逐项回执')
  138. para(doc, form_note, 10)
  139. para(doc, file_note, 10)
  140. table = doc.add_table(rows=1, cols=6)
  141. table.autofit = False
  142. widths = [Cm(v) for v in (1.15, 2.1, 4.4, 1.5, 4.6, 3.65)]
  143. for column, width in zip(table.columns, widths):
  144. column.width = width
  145. headers = ['关联项', '收件或\n答复状态', '文件名或\n书面答复', '字节数', 'SHA-256', '取得时间\n含时区']
  146. for cell, width, text in zip(table.rows[0].cells, widths, headers):
  147. cell.width = width
  148. font(cell.paragraphs[0].add_run(text), 9, True)
  149. cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
  150. shade = OxmlElement('w:shd'); shade.set(qn('w:fill'), 'E7EDF3')
  151. cell._tc.get_or_add_tcPr().append(shade)
  152. repeat = OxmlElement('w:tblHeader')
  153. table.rows[0]._tr.get_or_add_trPr().append(repeat)
  154. for r in receipt_rows:
  155. cells = table.add_row().cells
  156. for cell, width in zip(cells, widths):
  157. cell.width = width
  158. font(cells[0].paragraphs[0].add_run(r['itemId']), 9)
  159. font(cells[1].paragraphs[0].add_run('待回填'), 9)
  160. for row in table.rows:
  161. cant_split = OxmlElement('w:cantSplit')
  162. row._tr.get_or_add_trPr().append(cant_split)
  163. for i, cell in enumerate(row.cells):
  164. cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
  165. props = cell._tc.get_or_add_tcPr()
  166. margins = OxmlElement('w:tcMar')
  167. for side, value in (('top', 70), ('bottom', 70), ('left', 85), ('right', 85)):
  168. edge = OxmlElement('w:'+side); edge.set(qn('w:w'), str(value)); edge.set(qn('w:type'), 'dxa'); margins.append(edge)
  169. props.append(margins)
  170. borders = OxmlElement('w:tcBorders')
  171. for side in ('top', 'left', 'bottom', 'right'):
  172. edge = OxmlElement('w:'+side)
  173. for key, value in (('val', 'single'), ('sz', '4'), ('color', 'D9D9D9')):
  174. edge.set(qn('w:'+key), value)
  175. borders.append(edge)
  176. props.append(borders)
  177. for p in cell.paragraphs:
  178. if not p.runs:
  179. font(p.add_run(''), 9)
  180. p.paragraph_format.space_after = Pt(0)
  181. p.paragraph_format.space_before = Pt(0)
  182. p.paragraph_format.line_spacing = 1.0
  183. snap = OxmlElement('w:snapToGrid'); snap.set(qn('w:val'), 'false')
  184. p._p.get_or_add_pPr().append(snap)
  185. if i in (0, 1, 3, 5):
  186. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  187. para(doc, 'P01至P12对应上文政策文件,Q01至Q05对应上文商品榜5问。多个文件可续行;附件逐件记录。回执模板JSON中已保留完整文件ID及问题文本。', 9)
  188. footer = section.footer.paragraphs[0]
  189. footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  190. font(footer.add_run('v2 | '), 8)
  191. field = OxmlElement('w:fldSimple'); field.set(qn('w:instr'), 'PAGE')
  192. footer._p.append(field)
  193. doc.core_properties.title = '原件补充与榜单来源确认单'
  194. doc.core_properties.subject = '12份政策原件和商品榜来源5问及逐项回执'
  195. doc.core_properties.author = ''
  196. OUT.mkdir(parents=True)
  197. (OUT/'原件补充与榜单来源确认单-v2.md').write_text('\n'.join(md), encoding='utf-8')
  198. doc.save(OUT/'原件补充与榜单来源确认单-v2.docx')
  199. save_json(OUT/'逐项回执模板-v2.json', dict(schema='ksf.industry.source-confirmation-return-template.v2',
  200. templateOnly=True, newlyReceivedOriginals=0, newlyReceivedRankingSnapshots=0,
  201. items=receipt_rows, evidenceFileFields=['fileName', 'itemId', 'sourceUrl', 'bytes', 'sha256',
  202. 'obtainedAt', 'evidenceType', 'verificationStatus'],
  203. fileFieldRules='bytes为实际非负字节数,sha256为完整64位值,obtainedAt带时区;无文件保持空,不编造。'))
  204. save_json(WORK/'authoring-verification.json', dict(ok=True, inputSha256=sha(raw), inputManifest=3,
  205. markdownExactByteMatch=original == incoming_md, markdownNormalizedTextMatch=True,
  206. inputDocxFullDocumentIds=0, outputDocxFullDocumentIds=12, policyEntries=12,
  207. pendingClauses=19, rankingQuestions=5, emptyReceiptItems=17,
  208. additions=['截图辅助证据与完整网页存档候选分列', '不支持答复包含日期与范围', '逐项回执与文件证据字段', '补齐Word版完整文件ID'],
  209. createdAt=datetime.now(timezone.utc).isoformat(), paidCalls=0,
  210. externalMessagesSent=0, researchDatabaseWrites=0, productionChanges=0))
  211. print(json.dumps(dict(ok=True, output=str(OUT), policyEntries=12, rankingQuestions=5, receiptItems=17), ensure_ascii=False))
  212. if __name__ == '__main__':
  213. main()