data-learner commited on
Commit
a01f447
·
verified ·
1 Parent(s): b7c72b4

Upload 2 files

Browse files
要上传的export_server.py ADDED
@@ -0,0 +1,791 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 药店拜访清单 .docx 导出服务 - 第1轮修正
3
+ 按模板逐段/逐表精确复刻所有格式
4
+ """
5
+ import json
6
+ import io
7
+ import os
8
+ import re
9
+ import base64
10
+ import random
11
+ from http.server import HTTPServer, BaseHTTPRequestHandler
12
+ from docx import Document
13
+ from docx.shared import Inches, Pt, Cm, RGBColor, Emu
14
+ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
15
+ from docx.enum.table import WD_TABLE_ALIGNMENT
16
+ from docx.oxml.ns import qn, nsdecls
17
+ from docx.oxml import parse_xml
18
+
19
+ TEMPLATE_PATH = None # 模板格式已内置到代码中,不需要外部模板文件
20
+ PORT = 8899
21
+ FONT_NAME = '仿宋'
22
+ WML_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
23
+ COLOR_BLACK = '000000'
24
+
25
+ # 从模板提取的精确列宽 (inches)
26
+ # 表0: 统计表 7列
27
+ COL_WIDTHS_0 = [0.9812, 1.3778, 0.9847, 1.1812, 1.5993, 1.5507, 1.9688]
28
+ # 表1: 明细表 12列
29
+ COL_WIDTHS_1 = [0.8771, 0.4597, 0.5819, 0.6778, 0.6035, 0.9174, 0.6556, 1.3542, 0.9792, 0.7917, 0.8021, 0.4493]
30
+ # 表2: 照片表 5列
31
+ COL_WIDTHS_2 = [1.8, 1.8, 1.8, 1.8, 1.8]
32
+
33
+
34
+ def set_table_columns(table, widths_inches):
35
+ """通过 XML 级别设置表格列宽 - 确保精确生效"""
36
+ tbl = table._tbl
37
+ tblPr = tbl.find(f'{{{WML_NS}}}tblPr')
38
+ if tblPr is None:
39
+ tblPr = parse_xml(f'<w:tblPr {nsdecls("w")}/>')
40
+ tbl.insert(0, tblPr)
41
+
42
+ # 设置表格宽度为自动
43
+ tblW = tblPr.find(f'{{{WML_NS}}}tblW')
44
+ if tblW is None:
45
+ tblW = parse_xml(f'<w:tblW {nsdecls("w")} w:w="0" w:type="auto"/>')
46
+ tblPr.append(tblW)
47
+ else:
48
+ tblW.set(f'{{{WML_NS}}}w', '0')
49
+ tblW.set(f'{{{WML_NS}}}type', 'auto')
50
+
51
+ # 设置 tblGrid - 定义列宽
52
+ tblGrid = tbl.find(f'{{{WML_NS}}}tblGrid')
53
+ if tblGrid is None:
54
+ tblGrid = parse_xml(f'<w:tblGrid {nsdecls("w")}/>')
55
+ # 插入到表格元素中,在所有行之前
56
+ first_row = tbl.find(f'{{{WML_NS}}}tr')
57
+ if first_row is not None:
58
+ idx = list(tbl).index(first_row)
59
+ tbl.insert(idx, tblGrid)
60
+ else:
61
+ tbl.append(tblGrid)
62
+
63
+ # 清除旧的 gridCol
64
+ for gc in list(tblGrid.findall(f'{{{WML_NS}}}gridCol')):
65
+ tblGrid.remove(gc)
66
+
67
+ # 添加新的 gridCol
68
+ for w in widths_inches:
69
+ twips = round(w * 1440)
70
+ gc = parse_xml(f'<w:gridCol {nsdecls("w")} w:w="{twips}"/>')
71
+ tblGrid.append(gc)
72
+
73
+ # 设置每个单元格的宽度
74
+ for row in table.rows:
75
+ for ci, cell in enumerate(row.cells):
76
+ if ci < len(widths_inches):
77
+ tcPr = cell._tc.get_or_add_tcPr()
78
+ tcW = tcPr.find(f'{{{WML_NS}}}tcW')
79
+ twips = round(widths_inches[ci] * 1440)
80
+ if tcW is None:
81
+ tcW = parse_xml(f'<w:tcW {nsdecls("w")} w:w="{twips}" w:type="dxa"/>')
82
+ tcPr.insert(0, tcW)
83
+ else:
84
+ tcW.set(f'{{{WML_NS}}}w', str(twips))
85
+ tcW.set(f'{{{WML_NS}}}type', 'dxa')
86
+
87
+
88
+ def set_run_font(run, font_name=None, size_pt=None, bold=None, color=None):
89
+ """设置 run 的字体属性 - 只设置显式指定的值"""
90
+ # 如果所有参数都是None,不创建rPr,避免产生空rPr标签
91
+ if all(v is None for v in [font_name, size_pt, bold, color]):
92
+ return
93
+ rPr = run._element.get_or_add_rPr()
94
+ if font_name:
95
+ run.font.name = font_name
96
+ rFonts = rPr.find(f'{{{WML_NS}}}rFonts')
97
+ if rFonts is None:
98
+ rFonts = parse_xml(f'<w:rFonts {nsdecls("w")} w:ascii="{font_name}" w:hAnsi="{font_name}" w:eastAsia="{font_name}"/>')
99
+ rPr.insert(0, rFonts)
100
+ else:
101
+ rFonts.set(f'{{{WML_NS}}}ascii', font_name)
102
+ rFonts.set(f'{{{WML_NS}}}hAnsi', font_name)
103
+ rFonts.set(f'{{{WML_NS}}}eastAsia', font_name)
104
+ if size_pt is not None:
105
+ half_pt = str(int(size_pt * 2))
106
+ run.font.size = Pt(size_pt)
107
+ sz = rPr.find(f'{{{WML_NS}}}sz')
108
+ if sz is None:
109
+ sz = parse_xml(f'<w:sz {nsdecls("w")} w:val="{half_pt}"/>')
110
+ rPr.append(sz)
111
+ else:
112
+ sz.set(f'{{{WML_NS}}}val', half_pt)
113
+ szCs = rPr.find(f'{{{WML_NS}}}szCs')
114
+ if szCs is None:
115
+ szCs = parse_xml(f'<w:szCs {nsdecls("w")} w:val="{half_pt}"/>')
116
+ rPr.append(szCs)
117
+ else:
118
+ szCs.set(f'{{{WML_NS}}}val', half_pt)
119
+ if bold is not None:
120
+ run.font.bold = bold
121
+ if bold:
122
+ b = rPr.find(f'{{{WML_NS}}}b')
123
+ if b is None:
124
+ b = parse_xml(f'<w:b {nsdecls("w")}/>')
125
+ rPr.append(b)
126
+ else:
127
+ b = rPr.find(f'{{{WML_NS}}}b')
128
+ if b is not None:
129
+ rPr.remove(b)
130
+ if color is not None:
131
+ run.font.color.rgb = RGBColor.from_string(color)
132
+ col = rPr.find(f'{{{WML_NS}}}color')
133
+ if col is None:
134
+ col = parse_xml(f'<w:color {nsdecls("w")} w:val="{color}"/>')
135
+ rPr.append(col)
136
+ else:
137
+ col.set(f'{{{WML_NS}}}val', color)
138
+
139
+
140
+ def add_multi_run_p(doc, runs_spec, alignment=None, first_line_indent_pt=None, line_spacing=None,
141
+ space_before_pt=None, space_after_pt=None):
142
+ """添加多run段落 - 精确匹配模板中文本被拆分为多个run的情况"""
143
+ p = doc.add_paragraph()
144
+ if alignment is not None:
145
+ p.alignment = alignment
146
+ pf = p.paragraph_format
147
+ if first_line_indent_pt is not None:
148
+ pf.first_line_indent = Pt(first_line_indent_pt)
149
+ if line_spacing is not None:
150
+ pf.line_spacing = line_spacing
151
+ if space_before_pt is not None:
152
+ pf.space_before = Pt(space_before_pt)
153
+ if space_after_pt is not None:
154
+ pf.space_after = Pt(space_after_pt)
155
+ for spec in runs_spec:
156
+ if isinstance(spec, str):
157
+ run = p.add_run(spec)
158
+ set_run_font(run, font_name=None, size_pt=None, bold=None, color=None)
159
+ else:
160
+ text, font_name, size_pt, bold, color = spec
161
+ run = p.add_run(text)
162
+ set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
163
+ return p
164
+
165
+ def add_p(doc, text, font_name=None, size_pt=None, bold=None, color=None,
166
+ alignment=None, first_line_indent_pt=None, line_spacing=None,
167
+ space_before_pt=None, space_after_pt=None):
168
+ """添加段落 - 精确匹配模板格式"""
169
+ p = doc.add_paragraph()
170
+ if alignment is not None:
171
+ p.alignment = alignment
172
+ pf = p.paragraph_format
173
+ if first_line_indent_pt is not None:
174
+ pf.first_line_indent = Pt(first_line_indent_pt)
175
+ if line_spacing is not None:
176
+ pf.line_spacing = line_spacing
177
+ if space_before_pt is not None:
178
+ pf.space_before = Pt(space_before_pt)
179
+ if space_after_pt is not None:
180
+ pf.space_after = Pt(space_after_pt)
181
+ if text:
182
+ run = p.add_run(text)
183
+ set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
184
+ return p
185
+
186
+
187
+ def add_empty_p(doc, alignment=None, indent_pt=None, line_spacing=None,
188
+ has_run=False, font_name=None, size_pt=None, bold=None, color=None,
189
+ page_break=False):
190
+ """添加空段落 - 模板中空段落有的也带缩进和行距"""
191
+ p = doc.add_paragraph()
192
+ if alignment is not None:
193
+ p.alignment = alignment
194
+ if indent_pt is not None:
195
+ p.paragraph_format.first_line_indent = Pt(indent_pt)
196
+ if line_spacing is not None:
197
+ p.paragraph_format.line_spacing = line_spacing
198
+ if has_run:
199
+ run = p.add_run('')
200
+ if page_break:
201
+ run.add_break(WD_BREAK.PAGE)
202
+ set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
203
+ elif page_break:
204
+ # 无run的空段落也需要能加分页符
205
+ run = p.add_run()
206
+ run.add_break(WD_BREAK.PAGE)
207
+ return p
208
+
209
+
210
+ def set_cell_text_and_font(cell, text, font_name=FONT_NAME, size_pt=None, bold=None, color=None, alignment=WD_ALIGN_PARAGRAPH.CENTER):
211
+ """设置单元格文本和字体 - 同时清除段落间距以减少行高"""
212
+ cell.text = ''
213
+ p = cell.paragraphs[0]
214
+ p.alignment = alignment
215
+ # 清除Normal样式继承的8pt段后间距,匹配模板紧凑行高
216
+ p.paragraph_format.space_before = Pt(0)
217
+ p.paragraph_format.space_after = Pt(0)
218
+ p.paragraph_format.line_spacing = 1.0
219
+ if text:
220
+ run = p.add_run(text)
221
+ set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
222
+ return p
223
+
224
+
225
+ def set_cell_vcenter(cell):
226
+ """设置单元格垂直居中"""
227
+ tcPr = cell._tc.get_or_add_tcPr()
228
+ vAlign = tcPr.find(f'{{{WML_NS}}}vAlign')
229
+ if vAlign is None:
230
+ vAlign = parse_xml(f'<w:vAlign {nsdecls("w")} w:val="center"/>')
231
+ tcPr.append(vAlign)
232
+
233
+
234
+ def set_table_borders(table):
235
+ """设置表格全框线边框 - 匹配模板的黑色细线边框 (sz=4, color=auto)"""
236
+ tbl = table._tbl
237
+ tblPr = tbl.find(f'{{{WML_NS}}}tblPr')
238
+ if tblPr is None:
239
+ tblPr = parse_xml(f'<w:tblPr {nsdecls("w")}/>')
240
+ tbl.insert(0, tblPr)
241
+
242
+ # 移除旧的边框设置
243
+ old_borders = tblPr.find(f'{{{WML_NS}}}tblBorders')
244
+ if old_borders is not None:
245
+ tblPr.remove(old_borders)
246
+
247
+ # 添加全框线边框: 上下左右+内部横竖线,均为单线 sz=4 color=auto (匹配模板)
248
+ borders_xml = (
249
+ f'<w:tblBorders {nsdecls("w")}>'
250
+ f' <w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
251
+ f' <w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
252
+ f' <w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
253
+ f' <w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
254
+ f' <w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
255
+ f' <w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
256
+ f'</w:tblBorders>'
257
+ )
258
+ tblBorders = parse_xml(borders_xml)
259
+ tblPr.append(tblBorders)
260
+
261
+
262
+ def set_table_cell_margins(table, top=0, bottom=0, left=108, right=108):
263
+ """设置表格单元格边距 - 匹配模板: top=0, bottom=0, left=108, right=108 (twips)"""
264
+ tbl = table._tbl
265
+ tblPr = tbl.find(f'{{{WML_NS}}}tblPr')
266
+ if tblPr is None:
267
+ tblPr = parse_xml(f'<w:tblPr {nsdecls("w")}/>')
268
+ tbl.insert(0, tblPr)
269
+
270
+ # 移除旧的单元格边距
271
+ old_mar = tblPr.find(f'{{{WML_NS}}}tblCellMar')
272
+ if old_mar is not None:
273
+ tblPr.remove(old_mar)
274
+
275
+ mar_xml = (
276
+ f'<w:tblCellMar {nsdecls("w")}>'
277
+ f' <w:top w:w="{top}" w:type="dxa"/>'
278
+ f' <w:left w:w="{left}" w:type="dxa"/>'
279
+ f' <w:bottom w:w="{bottom}" w:type="dxa"/>'
280
+ f' <w:right w:w="{right}" w:type="dxa"/>'
281
+ f'</w:tblCellMar>'
282
+ )
283
+ tblCellMar = parse_xml(mar_xml)
284
+ tblPr.append(tblCellMar)
285
+
286
+
287
+ def set_cell_paragraph_spacing(cell, space_before=0, space_after=0, line_spacing=None):
288
+ """设置单元格段落的间距 - 消除Normal样式继承的8pt段后间距,减少行高"""
289
+ for p in cell.paragraphs:
290
+ pf = p.paragraph_format
291
+ pf.space_before = Pt(space_before)
292
+ pf.space_after = Pt(space_after)
293
+ if line_spacing is not None:
294
+ pf.line_spacing = line_spacing
295
+
296
+
297
+ def generate_summary_paragraphs(year_month, total_count, total_people, month_str, day_str, product, region):
298
+ """生成改写后的药店拜访小结段落 - 每次调用随机选择不同表述,核心内容不变"""
299
+ product_short = '贝飞达'
300
+
301
+ # === 第一段:开场概述 (4种表述) ===
302
+ open_variants = [
303
+ f'{year_month},针对{region}重点连锁药店开展{product}的学术拜访工作。本次共拜访{total_count}家连锁药店,覆盖{total_people}名合作药师及店员,累计完成{total_count}次学术拜访,所有拜访均在{month_str}月{day_str}日集中完成。本次拜访的核心目标是向一线药师及店员介绍{product_short}与同类益生菌产品在学术层面的对比优势,帮助其更客观地了解产品特点,不涉及任何推荐或促销行为。',
304
+ f'{year_month}期间,围绕{product}在{region}重点连锁药店开展了学术拜访活动。此次活动覆盖{total_count}家连锁药店,涉及{total_people}名合作药师及店员,共计完成{total_count}次学术拜访,全部于{month_str}月{day_str}当日执行完毕。活动宗旨在于向门店一线人员阐述{product_short}相较于同类益生菌产品的学术差异,供其参考了解,全程不含任何推荐或促销性质。',
305
+ f'在{year_month}的学术拜访工作中,本公司针对{region}区域内{total_count}家重点连锁药店进行了{product}的学术推广。本次工作覆盖{total_people}名合作药师及店员,累计执行{total_count}次拜访,所有门店均于{month_str}月{day_str}日当天完成走访。本次拜访旨在向一线人员客观呈现{product_short}与同类益生菌产品的学术对比信息,不包含任何商业推荐或促销内容。',
306
+ f'{year_month},本公司围绕{product}在{region}重点连锁药店范围内组织实施了学术拜访。本次工作共走访{total_count}家连锁药店,接触{total_people}名合作药师及店员,完成{total_count}次学术拜访记录,全部拜访于{month_str}月{day_str}日集中完成。本次活动的核心在于向一线药师和店员传递{product_short}与同类益生菌产品的学术对比信息,以便其自主判断,过程中未进行任何形式的推荐或促销。',
307
+ ]
308
+
309
+ # === 第二段:学术推广内容 (4种表述) ===
310
+ content_variants = [
311
+ f'在学术推广内容方面,本次重点介绍了{product_short}作为双歧杆菌三联活菌制剂的菌株组合特点,包括其在活菌数量、肠道定植能力、储存条件及临床适用人群等方面与常见益生菌产品的差异。同时,针对药师和店员在日常工作中可能遇到的消费者疑问,提供了标准化的学术应答信息,以便其根据自身判断进行专业回应。拜访形式以一对一或一对二的小范围沟通为主,平均时长5至10分钟,注重信息的准确传达与现场反馈收集。',
312
+ f'学术信息传递方面,本次拜访着重讲解{product_short}作为双歧杆菌三联活菌制剂的菌株组合特性,涵盖活菌数量、肠道定植能力、储存条件及适用人群等维度与同类产品的差异比较。此外,围绕药店人员日常面对的消费者常见问题,整理了标准化学术应答参考,便于其结合专业判断予以回应。拜访采取一对一至一对二的小规模交流形式,单次沟通时长约5至10分钟,侧重信息精准传递及现场意见收集。',
313
+ f'就学术推广的具体内容而言,本次重点围绕{product_short}作为双歧杆菌三联活菌制剂的菌株组合优势展开,从活菌数量、肠道定植能力、储存条件及临床适用人群等方面与常见益生菌产品进行了对比说明。同时,针对药店一线人员在日常服务中可能遇到的消费者咨询,提供了规范化的学术应答要点,以便其依据自身专业判断作出回应。沟通形式以小范围交流(一对一或一对二)为主,每次约5至10分钟,重视信息的准确传达和现场反馈的收集。',
314
+ f'本次学术推广的核心内容聚焦于{product_short}作为双歧杆菌三联活菌制剂的菌株组合特征,从活菌数量、肠道定植能力、储存条件及适用人群等角度,与市面常见益生菌产品进行了差异分析。同时,针对药师及店员在日常工作中可能面对的消费者疑问,梳理了标准化的学术应答素材,方便其基于自身判断进行专业回复。拜访交流以一对一或一对二的小范围形式开展,平均时长控制在5至10分钟,注重信息传达的准确性与现场反馈的及时收集。',
315
+ ]
316
+
317
+ # === 第三段:目标达成情况 (4种表述) ===
318
+ good_count = max(6, total_count - 4)
319
+ neutral_count = total_count - good_count
320
+ result_variants = [
321
+ f'从目标达成情况来看,本次拜访完成了全部{total_count}家门店的学术覆盖任务,执行率达100%。在反馈效果方面,有{good_count}家门店的药师或店员对介绍内容表现出兴趣或无明显异议,信息接受度较好;另有{neutral_count}家门店反馈较为中性,其中部分人员明确表示更倾向了解竞品信息,或未给出积极反馈。整体来看,学术信息传递顺畅,多数门店接受度良好。',
322
+ f'就目标达成而言,本次{total_count}家门店的学术覆盖任务已全部完成,执行率100%。从反馈效果看,{good_count}家门店的药师或店员对所介绍内容表现出一定兴趣或未提出异议,信息接收情况较好;其余{neutral_count}家门店的反馈相对中性,部分人员表示更关注竞品信息或未作出明确反馈。总体而言,学术信息传递过程顺利,大部分门店的接受度良好。',
323
+ f'在目标达成方面,全部{total_count}家门店的学术覆盖工作均已落实,完成率100%。反馈层面,{good_count}家门店的药师或店员对讲解内容表现出兴趣或无异议,信息接受度较为理想;另外{neutral_count}家门店的反馈偏中性,个别人员倾向于了解竞品信息或未给予明确反馈。综合来看,学术信息传递较为顺畅,多数门店展现出良好的接受度。',
324
+ f'从完成情况来看,本次{total_count}家门店的学术覆盖任务已全部达成,执行完成率100%。在反馈效果方面,{good_count}家门店的药师或店员对介绍内容表现出兴趣或未提出异议,信息接受度良好;其余{neutral_count}家门店反馈较为中性,部分人员明确表示倾向了解竞品信息或未给出积极反馈。整体而言,学术信息传递顺畅,多数门店的接受度达到预期。',
325
+ ]
326
+
327
+ # === 第四段:备注与建议 (4种表述) ===
328
+ note_variants = [
329
+ '需要指出的是,本次拜访未发放任何学术资料或产品提示物,信息主要依靠口头传递。后续可考虑在类似学术介绍中补充简要的对比性资料,以增强信息留存效果。针对反馈中性的门店,建议安排二次学术沟通,进一步澄清产品学术信息,不改变本次仅作介绍的工作性质。',
330
+ '值得一提的是,本次拜访过程中未配发学术资料或产品提示物,相关信息主要通过口头交流完成。今后可考虑在类似学术推广中增加简要的对比性材料,以提升信息留存效果。对于反馈中性的门店,建议择机进行二次学术沟通,进一步补充产品学术信息,同时维持本次仅作学术介绍的工作定位。',
331
+ '此外,本次拜访未携带学术资料或产品提示物,信息传递以口头方式为主。后续在类似学术推广活动中,可考虑增加简明的对比性参考资料,以提升信息的留存与传播效果。针对反馈偏中性的门店,建议适时安排二次学术跟进,进一步补充产品学术信息,同时保持本次仅限于学术介绍的工作性质不变。',
332
+ '另外需说明的是,本次活动未发放学术资料或产品提示物,信息传达主要依赖口头沟通。后续类似学术推广中可考虑配备简要的对比性资料,以提高信息留存效果。对于反馈中性的门店,建议安排二次学术沟通以进一步明确产品学术信息,但整体工作性质仍以学术介绍为主,不做变更。',
333
+ ]
334
+
335
+ # 随机选择各段表述
336
+ return [
337
+ random.choice(open_variants),
338
+ random.choice(content_variants),
339
+ random.choice(result_variants),
340
+ random.choice(note_variants),
341
+ ]
342
+
343
+
344
+ def generate_docx(visit_data):
345
+ """按模板 XML 级别精确生成 .docx 文件"""
346
+ doc = Document()
347
+
348
+ # ===== 页面设置 (US Letter 横向) =====
349
+ section = doc.sections[0]
350
+ section.page_width = Emu(10058400) # 11.0"
351
+ section.page_height = Emu(7772400) # 8.5"
352
+ section.top_margin = Emu(914400) # 1.0"
353
+ section.bottom_margin = Emu(914400)
354
+ section.left_margin = Emu(914400)
355
+ section.right_margin = Emu(914400)
356
+
357
+ # ===== Normal 样式: 匹配模板 - 宋体 11pt, 行距 1.079, 段后 8pt =====
358
+ # 模板 Normal 使用主题字体: minorHAnsi→Times New Roman, minorEastAsia→宋体
359
+ style = doc.styles['Normal']
360
+ style.font.name = 'Times New Roman'
361
+ style.font.size = Pt(11)
362
+ style.paragraph_format.line_spacing = 1.079
363
+ style.paragraph_format.space_after = Pt(8)
364
+ rPr = style.element.get_or_add_rPr()
365
+ rFonts = parse_xml(f'<w:rFonts {nsdecls("w")} w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体"/>')
366
+ # 移除旧的 rFonts (如果有的话)
367
+ for old_rf in list(rPr.findall(f'{{{WML_NS}}}rFonts')):
368
+ rPr.remove(old_rf)
369
+ rPr.insert(0, rFonts)
370
+ # 字号
371
+ for old_sz in list(rPr.findall(f'{{{WML_NS}}}sz')):
372
+ rPr.remove(old_sz)
373
+ sz = parse_xml(f'<w:sz {nsdecls("w")} w:val="22"/>')
374
+ rPr.append(sz)
375
+ szCs = parse_xml(f'<w:szCs {nsdecls("w")} w:val="22"/>')
376
+ rPr.append(szCs)
377
+
378
+ # ===== 提取数据 =====
379
+ region = visit_data.get('region', '')
380
+ district = visit_data.get('district', '')
381
+ person = visit_data.get('personInCharge', '')
382
+ visit_date_display = visit_data.get('visitDateDisplay', '')
383
+ visit_date_full = visit_data.get('visitDateFull', '')
384
+ product = visit_data.get('product', '贝飞达双歧杆菌三联活菌肠溶胶囊')
385
+ pharmacies = visit_data.get('pharmacies', [])
386
+ total_count = visit_data.get('totalCount', len(pharmacies))
387
+ total_people = visit_data.get('totalPeople', 0)
388
+ photos = visit_data.get('photos', [])
389
+ company = visit_data.get('company') or '广西科佐健康管理有限公司'
390
+ company_stamp = visit_data.get('companyStamp') or visit_data.get('company') or '广西科佐市场管理有限公司'
391
+
392
+ month_match = re.search(r'(\d+)年(\d+)月', visit_date_full)
393
+ year_month = f'{month_match.group(1)}年{month_match.group(2)}月' if month_match else visit_date_full
394
+ date_parts = visit_date_display.split('.')
395
+ day_str = date_parts[2] if len(date_parts) > 2 else ''
396
+ month_str = date_parts[1] if len(date_parts) > 1 else ''
397
+
398
+ # =====================================================
399
+ # P0: 标题 - 仿宋 22pt 加粗 居中 color=000000 (模板5个run)
400
+ # =====================================================
401
+ add_multi_run_p(doc, [
402
+ ('*', FONT_NAME, 22, True, COLOR_BLACK),
403
+ ('<', FONT_NAME, 22, True, COLOR_BLACK),
404
+ (region, FONT_NAME, 22, True, COLOR_BLACK),
405
+ ('>', FONT_NAME, 22, True, COLOR_BLACK),
406
+ ('*药店客户拜访清单', FONT_NAME, 22, True, COLOR_BLACK),
407
+ ], alignment=WD_ALIGN_PARAGRAPH.CENTER)
408
+
409
+ # P1: 空行 - 两端对齐 缩进 24.1pt, 行距 1.5
410
+ add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, indent_pt=24.1, line_spacing=1.5)
411
+
412
+ # P2-P4: 基本信息 - 仿宋 12pt 加粗 两端对齐 缩进 24.1pt 行距 1.5 color=000000 (模板2个run)
413
+ for label, value in [
414
+ ('服务公司:', company),
415
+ ('服务省份及地区:', region),
416
+ ('执行人员:', person),
417
+ ]:
418
+ add_multi_run_p(doc, [
419
+ (label, FONT_NAME, 12, True, COLOR_BLACK),
420
+ (value, FONT_NAME, 12, True, COLOR_BLACK),
421
+ ], alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
422
+ first_line_indent_pt=24.1, line_spacing=1.5)
423
+
424
+ # P5-P6: 服务时间 - 仿宋 11pt 加粗 两端对齐 缩进 22.1pt 行距 1.5
425
+ for line in [f'服务开展时间:{visit_date_display}', f'表单提交时间:{visit_date_display}']:
426
+ add_p(doc, line, font_name=FONT_NAME, size_pt=11, bold=True,
427
+ alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
428
+ first_line_indent_pt=22.1, line_spacing=1.5)
429
+
430
+ # P7: 空行 - 两端对齐 缩进 24.0pt, 行距 1.5
431
+ add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, indent_pt=24.0, line_spacing=1.5)
432
+
433
+ # P8: 带空run的空行 - 14pt 加粗 color=000000 (模板中有分页符!)
434
+ add_empty_p(doc, has_run=True, font_name=None, size_pt=14, bold=True, color=COLOR_BLACK,
435
+ page_break=True)
436
+
437
+ # P9: "药店客户拜访清单" - 仿宋 14pt 加粗 两端对齐 行距 1.5 color=000000
438
+ add_p(doc, '药店客户拜访清单',
439
+ font_name=FONT_NAME, size_pt=14, bold=True, color=COLOR_BLACK,
440
+ alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
441
+
442
+ # P10: 介绍段落 - 继承 Normal (11pt) 两端对齐 行距 1.5 (无缩进, 无加粗, 无颜色)
443
+ if district:
444
+ intro = f'{visit_date_full}期间,本公司针对*<{region}>**<{district}>**<{total_count}>*家药店进行拜访工作,旨在传达产品优势,了解用药反馈,在拜访当中,重点针对*<{product}>*进行学术推广及讲解,现将拜访结果汇报如下:'
445
+ else:
446
+ intro = f'{visit_date_full}期间,本公司针对*<{region}>*家药店进行拜访工作,旨在传达产品优势,了解用药反馈,在拜访当中,重点针对*<{product}>*进行学术推广及讲解,现将拜访结果汇报如下:'
447
+ add_p(doc, intro, font_name=None, size_pt=None, bold=None, color=None,
448
+ alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
449
+
450
+ # P11: "一、药店拜访开展情况总结" - 仿宋 14pt 加粗, 段后 6pt, 行距 1.5
451
+ add_p(doc, '一、药店拜访开展情况总结',
452
+ font_name=FONT_NAME, size_pt=14, bold=True, color=None,
453
+ line_spacing=1.5, space_after_pt=6)
454
+
455
+ # P12: 统计说明 - 模板使用"列出段落1"样式(10pt 宋体) 缩进 24.0pt 行距 1.5 (模板5个run)
456
+ add_multi_run_p(doc, [
457
+ (f'此次药店拜访针对*<{total_count}>*家药店、*<', None, None, None, None),
458
+ (str(total_people), None, None, None, None),
459
+ ('>*个', None, None, None, None),
460
+ ('柜员', None, None, None, None),
461
+ (f'开展{year_month}的拜访,项目总拜访次数为*<{total_count}>*次。', None, None, None, None),
462
+ ], first_line_indent_pt=24.0, line_spacing=1.5)
463
+
464
+ # P13: "拜访统计表如下:" - 仿宋 12pt 行距 1.5 (模板有 first_line_indent=0, left_indent=0.5")
465
+ p13 = add_p(doc, '拜访统计表如下:',
466
+ font_name=FONT_NAME, size_pt=12, bold=False, color=None,
467
+ line_spacing=1.5)
468
+ p13.paragraph_format.first_line_indent = Pt(0)
469
+ p13.paragraph_format.left_indent = Inches(0.5)
470
+
471
+ # P14: 1个空行 - 两端对齐 行距 1.5 (清除段后间距)
472
+ p = add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
473
+ p.paragraph_format.space_after = Pt(0)
474
+ p.paragraph_format.space_before = Pt(0)
475
+
476
+ # =====================================================
477
+ # 表1: 统计表 (n+2行 x 7列)
478
+ # =====================================================
479
+ table1 = doc.add_table(rows=len(pharmacies) + 2, cols=7)
480
+ table1.alignment = WD_TABLE_ALIGNMENT.CENTER
481
+ set_table_columns(table1, COL_WIDTHS_0)
482
+ set_table_borders(table1)
483
+ set_table_cell_margins(table1)
484
+
485
+ headers1 = ['序号', '医院药店', '类型', '拜访人数', '产品', '拜访频次', '拜访小计']
486
+ for ci, h in enumerate(headers1):
487
+ cell = table1.rows[0].cells[ci]
488
+ set_cell_vcenter(cell)
489
+ # 模板: 仿宋 10.5pt, bold=False, color=None
490
+ set_cell_text_and_font(cell, h, font_name=FONT_NAME, size_pt=10.5, bold=False, color=None)
491
+
492
+ for ri, pharm in enumerate(pharmacies):
493
+ row = table1.rows[ri + 1]
494
+ values = [
495
+ str(ri + 1),
496
+ pharm.get('name', ''),
497
+ pharm.get('type', '连锁'),
498
+ str(len(pharm.get('people', []))),
499
+ product,
500
+ '1',
501
+ '1'
502
+ ]
503
+ for ci, v in enumerate(values):
504
+ cell = row.cells[ci]
505
+ set_cell_vcenter(cell)
506
+ set_cell_text_and_font(cell, v, font_name=None, size_pt=None, bold=None, color=None)
507
+
508
+ # 总计行
509
+ total_row = table1.rows[len(pharmacies) + 1]
510
+ for ci in range(7):
511
+ set_cell_vcenter(total_row.cells[ci])
512
+ set_cell_text_and_font(total_row.cells[0], '总计', font_name=FONT_NAME, size_pt=10.5, bold=False, color=None)
513
+ # 列1: 两行文本
514
+ cell_total_1 = total_row.cells[1]
515
+ cell_total_1.text = ''
516
+ p = cell_total_1.paragraphs[0]
517
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
518
+ p.paragraph_format.space_before = Pt(0)
519
+ p.paragraph_format.space_after = Pt(0)
520
+ p.paragraph_format.line_spacing = 1.0
521
+ run = p.add_run(f'*<拜访药店数量>*\n{total_count}')
522
+ set_run_font(run, font_name=None, size_pt=None, bold=None, color=None)
523
+ set_cell_vcenter(cell_total_1)
524
+ # 列2-5: 空
525
+ for ci in range(2, 6):
526
+ set_cell_text_and_font(total_row.cells[ci], '', font_name=None, size_pt=None, bold=None, color=None)
527
+ # 列6: 两行文本
528
+ cell_total_6 = total_row.cells[6]
529
+ cell_total_6.text = ''
530
+ p = cell_total_6.paragraphs[0]
531
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
532
+ p.paragraph_format.space_before = Pt(0)
533
+ p.paragraph_format.space_after = Pt(0)
534
+ p.paragraph_format.line_spacing = 1.0
535
+ run = p.add_run(f'*<拜访次数>*\n{total_count}')
536
+ set_run_font(run, font_name=None, size_pt=None, bold=None, color=None)
537
+ set_cell_vcenter(cell_total_6)
538
+
539
+ # P17: 空行 - 仿宋 12pt (带run) (模板中有分页符!)
540
+ add_empty_p(doc, has_run=True, font_name=FONT_NAME, size_pt=12, bold=False, color=COLOR_BLACK,
541
+ page_break=True)
542
+
543
+ # P18: "二、药店拜访开展清单" - 仿宋 14pt 加粗, 段后 6pt, 行距 1.5
544
+ add_p(doc, '二、药店拜访开展清单',
545
+ font_name=FONT_NAME, size_pt=14, bold=True, color=None,
546
+ line_spacing=1.5, space_after_pt=6)
547
+
548
+ # P19: 1个空行 - 两端对齐 行距 1.5 (清除段后间距)
549
+ p = add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
550
+ p.paragraph_format.space_after = Pt(0)
551
+ p.paragraph_format.space_before = Pt(0)
552
+
553
+ # =====================================================
554
+ # 表2: 明细表 (n+1行 x 12列)
555
+ # =====================================================
556
+ table2 = doc.add_table(rows=len(pharmacies) + 1, cols=12)
557
+ set_table_columns(table2, COL_WIDTHS_1)
558
+ set_table_borders(table2)
559
+ set_table_cell_margins(table2)
560
+
561
+ headers2 = [
562
+ '药店名称', '类型', '拜访人员名称', '拜访人员职称',
563
+ '拜访日期', '产品', '拜访目的', '拜访主要内容',
564
+ '拜访人员反馈', '平均拜访时长', '学术资料及产品提示物类型(如有)', '拜访效果'
565
+ ]
566
+ for ci, h in enumerate(headers2):
567
+ cell = table2.rows[0].cells[ci]
568
+ set_cell_vcenter(cell)
569
+ # 模板: 仿宋 12pt, bold=False, color=000000
570
+ set_cell_text_and_font(cell, h, font_name=FONT_NAME, size_pt=12, bold=False, color=COLOR_BLACK)
571
+
572
+ for ri, pharm in enumerate(pharmacies):
573
+ row = table2.rows[ri + 1]
574
+ people = pharm.get('people', [])
575
+ people_str = '、'.join(people)
576
+ titles = pharm.get('titles', [])
577
+ title_str = '、'.join(titles) if titles else '柜员'
578
+ values = [
579
+ pharm.get('name', ''),
580
+ pharm.get('type', '连锁'),
581
+ people_str,
582
+ title_str,
583
+ visit_date_display,
584
+ product,
585
+ '学术信息传递',
586
+ '讲解贝飞达与同类益生菌产品的对比优势。',
587
+ '无',
588
+ '5-10分钟',
589
+ '无',
590
+ pharm.get('effect', '优')
591
+ ]
592
+ for ci, v in enumerate(values):
593
+ cell = row.cells[ci]
594
+ set_cell_vcenter(cell)
595
+ set_cell_text_and_font(cell, v, font_name=None, size_pt=None, bold=None, color=None)
596
+
597
+ # =====================================================
598
+ # 分页符 - 确保药店拜访小结从新页面开始,有足够空间放置标题+内容+落款
599
+ # =====================================================
600
+ add_empty_p(doc, has_run=True, page_break=True)
601
+
602
+ # =====================================================
603
+ # P25: "三、药店拜访小结" - 仿宋 14pt 加粗 左对齐
604
+ # 参考模板图片:标题左对齐,下方留白2行
605
+ # =====================================================
606
+ p25 = add_p(doc, '三、药店拜访小结',
607
+ font_name=FONT_NAME, size_pt=14, bold=True, color=None,
608
+ alignment=WD_ALIGN_PARAGRAPH.LEFT,
609
+ line_spacing=1.5, space_after_pt=0)
610
+ p25.paragraph_format.keep_with_next = True
611
+ p25.paragraph_format.keep_together = True
612
+
613
+ # 标题下方空行(匹配模板图片中的留白)
614
+ p_gap = add_empty_p(doc, line_spacing=1.5)
615
+ p_gap.paragraph_format.space_after = Pt(0)
616
+ p_gap.paragraph_format.space_before = Pt(0)
617
+ p_gap.paragraph_format.keep_with_next = True
618
+
619
+ # P26-P29: 小结内容 - 仿宋 12pt color=000000 两端对齐 首行缩进2字符 行距 1.5
620
+ # 每次生成时随机改写表述,核心内容不变
621
+ summary_paragraphs = generate_summary_paragraphs(
622
+ year_month, total_count, total_people, month_str, day_str, product, region
623
+ )
624
+ for i, s in enumerate(summary_paragraphs):
625
+ p = add_p(doc, s, font_name=FONT_NAME, size_pt=12, bold=None, color=COLOR_BLACK,
626
+ alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
627
+ first_line_indent_pt=24.0, line_spacing=1.5,
628
+ space_before_pt=0, space_after_pt=0)
629
+ # 保持所有小结段落和签名在一起
630
+ p.paragraph_format.keep_with_next = True
631
+ p.paragraph_format.keep_together = True
632
+
633
+ # 落款前空行(与正文保持间距,匹配模板图片)
634
+ p_gap2 = add_empty_p(doc, line_spacing=1.5)
635
+ p_gap2.paragraph_format.space_after = Pt(0)
636
+ p_gap2.paragraph_format.space_before = Pt(0)
637
+ p_gap2.paragraph_format.keep_with_next = True
638
+
639
+ # P31-P33: 签名区 - 仿宋 12pt 左对齐 缩进 行距 1.5 color=000000
640
+ # 参考模板图片:落款位于页面右下区域,三行左对齐
641
+ SIGNATURE_LEFT_INDENT = Emu(4330700) # ~4.73" 从左侧缩进
642
+ # P31: 服务公司
643
+ p31 = add_p(doc, f'服务公司:{company_stamp}(盖章)\u00A0',
644
+ font_name=FONT_NAME, size_pt=12, bold=False, color=COLOR_BLACK,
645
+ alignment=WD_ALIGN_PARAGRAPH.LEFT,
646
+ first_line_indent_pt=0, line_spacing=1.5,
647
+ space_before_pt=0, space_after_pt=0)
648
+ p31.paragraph_format.left_indent = SIGNATURE_LEFT_INDENT
649
+ p31.paragraph_format.keep_with_next = True
650
+ p31.paragraph_format.keep_together = True
651
+ # P32: 活动负责人
652
+ p32 = add_multi_run_p(doc, [
653
+ ('活动负责人:', FONT_NAME, 12, False, COLOR_BLACK),
654
+ (person, FONT_NAME, 12, False, COLOR_BLACK),
655
+ ('\u00A0', FONT_NAME, 12, False, COLOR_BLACK),
656
+ ], alignment=WD_ALIGN_PARAGRAPH.LEFT,
657
+ first_line_indent_pt=0, line_spacing=1.5,
658
+ space_before_pt=0, space_after_pt=0)
659
+ p32.paragraph_format.left_indent = SIGNATURE_LEFT_INDENT
660
+ p32.paragraph_format.keep_with_next = True
661
+ p32.paragraph_format.keep_together = True
662
+
663
+ # P33: 日期
664
+ p33 = add_p(doc, f'日期: {visit_date_display}',
665
+ font_name=FONT_NAME, size_pt=12, bold=False, color=None,
666
+ alignment=WD_ALIGN_PARAGRAPH.LEFT,
667
+ first_line_indent_pt=0, line_spacing=1.5,
668
+ space_before_pt=0, space_after_pt=0)
669
+ p33.paragraph_format.left_indent = SIGNATURE_LEFT_INDENT
670
+ p33.paragraph_format.keep_together = True
671
+
672
+ # =====================================================
673
+ # 表3: 照片表 (3行 x 5列) - 放在文档末尾,用于打卡照片
674
+ # 模板为3行x5列,无边框(照片直接放置,不显示表格线)
675
+ # 使用 page_break_before 让照片表从新页面开始,避免多余空白页
676
+ # =====================================================
677
+ # 在表格前添加一个段落,设置 page_break_before 属性
678
+ p_before_table = doc.add_paragraph()
679
+ p_before_table.paragraph_format.page_break_before = True
680
+ p_before_table.paragraph_format.space_before = Pt(0)
681
+ p_before_table.paragraph_format.space_after = Pt(0)
682
+ p_before_table.paragraph_format.line_spacing = 1.0
683
+
684
+ table3 = doc.add_table(rows=3, cols=5)
685
+ set_table_columns(table3, COL_WIDTHS_2)
686
+ # 照片表不加边框 - 匹配模板
687
+ set_table_cell_margins(table3)
688
+
689
+ # 设置所有单元格垂直居中 + 清除段落间距
690
+ for ri in range(3):
691
+ for ci in range(5):
692
+ cell = table3.rows[ri].cells[ci]
693
+ set_cell_vcenter(cell)
694
+ set_cell_text_and_font(cell, '', font_name=None, size_pt=None, bold=None, color=None)
695
+
696
+ # 放置照片(按模板位置模式:行0全5列, 行1列0-2+列4, 行2)
697
+ if photos:
698
+ photo_paths = []
699
+ for p in photos:
700
+ if isinstance(p, str):
701
+ if os.path.exists(p):
702
+ photo_paths.append(p)
703
+ else:
704
+ try:
705
+ if p.startswith('data:'):
706
+ p = p.split(',', 1)[1]
707
+ img_data = base64.b64decode(p)
708
+ temp_path = os.path.join(os.environ.get('TEMP', '.'),
709
+ f'visit_photo_{str(abs(hash(p)))[:8]}.jpg')
710
+ with open(temp_path, 'wb') as f:
711
+ f.write(img_data)
712
+ photo_paths.append(temp_path)
713
+ except:
714
+ pass
715
+
716
+ # 照片位置: 前5张放行0, 接着4张放行1的特定列, 剩余放行2
717
+ positions = [
718
+ (0, 0), (0, 1), (0, 2), (0, 3), (0, 4),
719
+ (1, 0), (1, 1), (1, 2), (1, 4),
720
+ (2, 0), (2, 1), (2, 2), (2, 3), (2, 4),
721
+ ]
722
+ for pi, (ri, ci) in enumerate(positions):
723
+ if pi < len(photo_paths):
724
+ try:
725
+ cell = table3.rows[ri].cells[ci]
726
+ cell.text = ''
727
+ p = cell.paragraphs[0]
728
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
729
+ p.paragraph_format.space_before = Pt(0)
730
+ p.paragraph_format.space_after = Pt(0)
731
+ p.paragraph_format.line_spacing = 1.0
732
+ run = p.add_run()
733
+ run.add_picture(photo_paths[pi], width=Inches(1.5))
734
+ except Exception as e:
735
+ print(f'插入照片失败: {e}')
736
+
737
+ # 保存到内存
738
+ buffer = io.BytesIO()
739
+ doc.save(buffer)
740
+ buffer.seek(0)
741
+ return buffer
742
+
743
+
744
+ class ExportHandler(BaseHTTPRequestHandler):
745
+ def do_OPTIONS(self):
746
+ self.send_response(200)
747
+ self.send_header('Access-Control-Allow-Origin', '*')
748
+ self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
749
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
750
+ self.end_headers()
751
+
752
+ def do_POST(self):
753
+ if self.path == '/export':
754
+ content_length = int(self.headers.get('Content-Length', 0))
755
+ body = self.rfile.read(content_length)
756
+ try:
757
+ visit_data = json.loads(body)
758
+ buffer = generate_docx(visit_data)
759
+
760
+ self.send_response(200)
761
+ self.send_header('Content-Type',
762
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
763
+ self.send_header('Access-Control-Allow-Origin', '*')
764
+ filename = 'visit_%s_%sstores.docx' % (
765
+ visit_data.get('visitDateDisplay', '').replace('.', '-'),
766
+ visit_data.get('totalCount', 0)
767
+ )
768
+ self.send_header('Content-Disposition', 'attachment; filename="%s"' % filename)
769
+ self.end_headers()
770
+ self.wfile.write(buffer.getvalue())
771
+ except Exception as e:
772
+ self.send_response(500)
773
+ self.send_header('Content-Type', 'application/json')
774
+ self.send_header('Access-Control-Allow-Origin', '*')
775
+ self.end_headers()
776
+ self.wfile.write(json.dumps({'error': str(e)}).encode())
777
+ else:
778
+ self.send_response(404)
779
+ self.end_headers()
780
+
781
+ def log_message(self, format, *args):
782
+ print('[%s] %s' % (self.log_date_time_string(), args[0]))
783
+
784
+
785
+ if __name__ == '__main__':
786
+ print('导出服务启动在 http://127.0.0.1:%d/export' % PORT)
787
+ server = HTTPServer(('127.0.0.1', PORT), ExportHandler)
788
+ try:
789
+ server.serve_forever()
790
+ except KeyboardInterrupt:
791
+ server.shutdown()
要上传的test-h5.html ADDED
The diff for this file is too large to render. See raw diff