Spaces:
Running
Running
Upload 2 files
Browse files- 要上传的export_server.py +791 -0
- 要上传的test-h5.html +0 -0
要上传的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
|
|
|