pharmacy / export_server.py
data-learner's picture
Upload export_server.py with huggingface_hub
8f49b74 verified
Raw History Blame Contribute Delete
40.7 kB
"""
药店拜访清单 .docx 导出服务 - 第1轮修正
按模板逐段/逐表精确复刻所有格式
"""
import json
import io
import os
import re
import base64
import random
from http.server import HTTPServer, BaseHTTPRequestHandler
from docx import Document
from docx.shared import Inches, Pt, Cm, RGBColor, Emu
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn, nsdecls
from docx.oxml import parse_xml
TEMPLATE_PATH = None # 模板格式已内置到代码中,不需要外部模板文件
PORT = 8899
FONT_NAME = '仿宋'
WML_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
COLOR_BLACK = '000000'
# 从模板提取的精确列宽 (inches)
# 表0: 统计表 7列
COL_WIDTHS_0 = [0.9812, 1.3778, 0.9847, 1.1812, 1.5993, 1.5507, 1.9688]
# 表1: 明细表 12列
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]
# 表2: 照片表 5列
COL_WIDTHS_2 = [1.8, 1.8, 1.8, 1.8, 1.8]
def set_table_columns(table, widths_inches):
"""通过 XML 级别设置表格列宽 - 确保精确生效"""
tbl = table._tbl
tblPr = tbl.find(f'{{{WML_NS}}}tblPr')
if tblPr is None:
tblPr = parse_xml(f'<w:tblPr {nsdecls("w")}/>')
tbl.insert(0, tblPr)
# 设置表格宽度为自动
tblW = tblPr.find(f'{{{WML_NS}}}tblW')
if tblW is None:
tblW = parse_xml(f'<w:tblW {nsdecls("w")} w:w="0" w:type="auto"/>')
tblPr.append(tblW)
else:
tblW.set(f'{{{WML_NS}}}w', '0')
tblW.set(f'{{{WML_NS}}}type', 'auto')
# 设置 tblGrid - 定义列宽
tblGrid = tbl.find(f'{{{WML_NS}}}tblGrid')
if tblGrid is None:
tblGrid = parse_xml(f'<w:tblGrid {nsdecls("w")}/>')
# 插入到表格元素中,在所有行之前
first_row = tbl.find(f'{{{WML_NS}}}tr')
if first_row is not None:
idx = list(tbl).index(first_row)
tbl.insert(idx, tblGrid)
else:
tbl.append(tblGrid)
# 清除旧的 gridCol
for gc in list(tblGrid.findall(f'{{{WML_NS}}}gridCol')):
tblGrid.remove(gc)
# 添加新的 gridCol
for w in widths_inches:
twips = round(w * 1440)
gc = parse_xml(f'<w:gridCol {nsdecls("w")} w:w="{twips}"/>')
tblGrid.append(gc)
# 设置每个单元格的宽度
for row in table.rows:
for ci, cell in enumerate(row.cells):
if ci < len(widths_inches):
tcPr = cell._tc.get_or_add_tcPr()
tcW = tcPr.find(f'{{{WML_NS}}}tcW')
twips = round(widths_inches[ci] * 1440)
if tcW is None:
tcW = parse_xml(f'<w:tcW {nsdecls("w")} w:w="{twips}" w:type="dxa"/>')
tcPr.insert(0, tcW)
else:
tcW.set(f'{{{WML_NS}}}w', str(twips))
tcW.set(f'{{{WML_NS}}}type', 'dxa')
def set_run_font(run, font_name=None, size_pt=None, bold=None, color=None):
"""设置 run 的字体属性 - 只设置显式指定的值"""
# 如果所有参数都是None,不创建rPr,避免产生空rPr标签
if all(v is None for v in [font_name, size_pt, bold, color]):
return
rPr = run._element.get_or_add_rPr()
if font_name:
run.font.name = font_name
rFonts = rPr.find(f'{{{WML_NS}}}rFonts')
if rFonts is None:
rFonts = parse_xml(f'<w:rFonts {nsdecls("w")} w:ascii="{font_name}" w:hAnsi="{font_name}" w:eastAsia="{font_name}"/>')
rPr.insert(0, rFonts)
else:
rFonts.set(f'{{{WML_NS}}}ascii', font_name)
rFonts.set(f'{{{WML_NS}}}hAnsi', font_name)
rFonts.set(f'{{{WML_NS}}}eastAsia', font_name)
if size_pt is not None:
half_pt = str(int(size_pt * 2))
run.font.size = Pt(size_pt)
sz = rPr.find(f'{{{WML_NS}}}sz')
if sz is None:
sz = parse_xml(f'<w:sz {nsdecls("w")} w:val="{half_pt}"/>')
rPr.append(sz)
else:
sz.set(f'{{{WML_NS}}}val', half_pt)
szCs = rPr.find(f'{{{WML_NS}}}szCs')
if szCs is None:
szCs = parse_xml(f'<w:szCs {nsdecls("w")} w:val="{half_pt}"/>')
rPr.append(szCs)
else:
szCs.set(f'{{{WML_NS}}}val', half_pt)
if bold is not None:
run.font.bold = bold
if bold:
b = rPr.find(f'{{{WML_NS}}}b')
if b is None:
b = parse_xml(f'<w:b {nsdecls("w")}/>')
rPr.append(b)
else:
b = rPr.find(f'{{{WML_NS}}}b')
if b is not None:
rPr.remove(b)
if color is not None:
run.font.color.rgb = RGBColor.from_string(color)
col = rPr.find(f'{{{WML_NS}}}color')
if col is None:
col = parse_xml(f'<w:color {nsdecls("w")} w:val="{color}"/>')
rPr.append(col)
else:
col.set(f'{{{WML_NS}}}val', color)
def add_multi_run_p(doc, runs_spec, alignment=None, first_line_indent_pt=None, line_spacing=None,
space_before_pt=None, space_after_pt=None):
"""添加多run段落 - 精确匹配模板中文本被拆分为多个run的情况"""
p = doc.add_paragraph()
if alignment is not None:
p.alignment = alignment
pf = p.paragraph_format
if first_line_indent_pt is not None:
pf.first_line_indent = Pt(first_line_indent_pt)
if line_spacing is not None:
pf.line_spacing = line_spacing
if space_before_pt is not None:
pf.space_before = Pt(space_before_pt)
if space_after_pt is not None:
pf.space_after = Pt(space_after_pt)
for spec in runs_spec:
if isinstance(spec, str):
run = p.add_run(spec)
set_run_font(run, font_name=None, size_pt=None, bold=None, color=None)
else:
text, font_name, size_pt, bold, color = spec
run = p.add_run(text)
set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
return p
def add_p(doc, text, font_name=None, size_pt=None, bold=None, color=None,
alignment=None, first_line_indent_pt=None, line_spacing=None,
space_before_pt=None, space_after_pt=None):
"""添加段落 - 精确匹配模板格式"""
p = doc.add_paragraph()
if alignment is not None:
p.alignment = alignment
pf = p.paragraph_format
if first_line_indent_pt is not None:
pf.first_line_indent = Pt(first_line_indent_pt)
if line_spacing is not None:
pf.line_spacing = line_spacing
if space_before_pt is not None:
pf.space_before = Pt(space_before_pt)
if space_after_pt is not None:
pf.space_after = Pt(space_after_pt)
if text:
run = p.add_run(text)
set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
return p
def add_empty_p(doc, alignment=None, indent_pt=None, line_spacing=None,
has_run=False, font_name=None, size_pt=None, bold=None, color=None,
page_break=False):
"""添加空段落 - 模板中空段落有的也带缩进和行距"""
p = doc.add_paragraph()
if alignment is not None:
p.alignment = alignment
if indent_pt is not None:
p.paragraph_format.first_line_indent = Pt(indent_pt)
if line_spacing is not None:
p.paragraph_format.line_spacing = line_spacing
if has_run:
run = p.add_run('')
if page_break:
run.add_break(WD_BREAK.PAGE)
set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
elif page_break:
# 无run的空段落也需要能加分页符
run = p.add_run()
run.add_break(WD_BREAK.PAGE)
return p
def set_cell_text_and_font(cell, text, font_name=FONT_NAME, size_pt=None, bold=None, color=None, alignment=WD_ALIGN_PARAGRAPH.CENTER):
"""设置单元格文本和字体 - 同时清除段落间距以减少行高"""
cell.text = ''
p = cell.paragraphs[0]
p.alignment = alignment
# 清除Normal样式继承的8pt段后间距,匹配模板紧凑行高
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.0
if text:
run = p.add_run(text)
set_run_font(run, font_name=font_name, size_pt=size_pt, bold=bold, color=color)
return p
def set_cell_vcenter(cell):
"""设置单元格垂直居中"""
tcPr = cell._tc.get_or_add_tcPr()
vAlign = tcPr.find(f'{{{WML_NS}}}vAlign')
if vAlign is None:
vAlign = parse_xml(f'<w:vAlign {nsdecls("w")} w:val="center"/>')
tcPr.append(vAlign)
def set_table_borders(table):
"""设置表格全框线边框 - 匹配模板的黑色细线边框 (sz=4, color=auto)"""
tbl = table._tbl
tblPr = tbl.find(f'{{{WML_NS}}}tblPr')
if tblPr is None:
tblPr = parse_xml(f'<w:tblPr {nsdecls("w")}/>')
tbl.insert(0, tblPr)
# 移除旧的边框设置
old_borders = tblPr.find(f'{{{WML_NS}}}tblBorders')
if old_borders is not None:
tblPr.remove(old_borders)
# 添加全框线边框: 上下左右+内部横竖线,均为单线 sz=4 color=auto (匹配模板)
borders_xml = (
f'<w:tblBorders {nsdecls("w")}>'
f' <w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
f' <w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
f' <w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
f' <w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
f' <w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
f' <w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
f'</w:tblBorders>'
)
tblBorders = parse_xml(borders_xml)
tblPr.append(tblBorders)
def set_table_cell_margins(table, top=0, bottom=0, left=108, right=108):
"""设置表格单元格边距 - 匹配模板: top=0, bottom=0, left=108, right=108 (twips)"""
tbl = table._tbl
tblPr = tbl.find(f'{{{WML_NS}}}tblPr')
if tblPr is None:
tblPr = parse_xml(f'<w:tblPr {nsdecls("w")}/>')
tbl.insert(0, tblPr)
# 移除旧的单元格边距
old_mar = tblPr.find(f'{{{WML_NS}}}tblCellMar')
if old_mar is not None:
tblPr.remove(old_mar)
mar_xml = (
f'<w:tblCellMar {nsdecls("w")}>'
f' <w:top w:w="{top}" w:type="dxa"/>'
f' <w:left w:w="{left}" w:type="dxa"/>'
f' <w:bottom w:w="{bottom}" w:type="dxa"/>'
f' <w:right w:w="{right}" w:type="dxa"/>'
f'</w:tblCellMar>'
)
tblCellMar = parse_xml(mar_xml)
tblPr.append(tblCellMar)
def set_cell_paragraph_spacing(cell, space_before=0, space_after=0, line_spacing=None):
"""设置单元格段落的间距 - 消除Normal样式继承的8pt段后间距,减少行高"""
for p in cell.paragraphs:
pf = p.paragraph_format
pf.space_before = Pt(space_before)
pf.space_after = Pt(space_after)
if line_spacing is not None:
pf.line_spacing = line_spacing
def generate_summary_paragraphs(year_month, total_count, total_people, month_str, day_str, product, region):
"""生成改写后的药店拜访小结段落 - 每次调用随机选择不同表述,核心内容不变"""
product_short = '贝飞达'
# === 第一段:开场概述 (4种表述) ===
open_variants = [
f'{year_month},针对{region}重点连锁药店开展{product}的学术拜访工作。本次共拜访{total_count}家连锁药店,覆盖{total_people}名合作药师及店员,累计完成{total_count}次学术拜访,所有拜访均在{month_str}月{day_str}日集中完成。本次拜访的核心目标是向一线药师及店员介绍{product_short}与同类益生菌产品在学术层面的对比优势,帮助其更客观地了解产品特点,不涉及任何推荐或促销行为。',
f'{year_month}期间,围绕{product}在{region}重点连锁药店开展了学术拜访活动。此次活动覆盖{total_count}家连锁药店,涉及{total_people}名合作药师及店员,共计完成{total_count}次学术拜访,全部于{month_str}月{day_str}当日执行完毕。活动宗旨在于向门店一线人员阐述{product_short}相较于同类益生菌产品的学术差异,供其参考了解,全程不含任何推荐或促销性质。',
f'在{year_month}的学术拜访工作中,本公司针对{region}区域内{total_count}家重点连锁药店进行了{product}的学术推广。本次工作覆盖{total_people}名合作药师及店员,累计执行{total_count}次拜访,所有门店均于{month_str}月{day_str}日当天完成走访。本次拜访旨在向一线人员客观呈现{product_short}与同类益生菌产品的学术对比信息,不包含任何商业推荐或促销内容。',
f'{year_month},本公司围绕{product}在{region}重点连锁药店范围内组织实施了学术拜访。本次工作共走访{total_count}家连锁药店,接触{total_people}名合作药师及店员,完成{total_count}次学术拜访记录,全部拜访于{month_str}月{day_str}日集中完成。本次活动的核心在于向一线药师和店员传递{product_short}与同类益生菌产品的学术对比信息,以便其自主判断,过程中未进行任何形式的推荐或促销。',
]
# === 第二段:学术推广内容 (4种表述) ===
content_variants = [
f'在学术推广内容方面,本次重点介绍了{product_short}作为双歧杆菌三联活菌制剂的菌株组合特点,包括其在活菌数量、肠道定植能力、储存条件及临床适用人群等方面与常见益生菌产品的差异。同时,针对药师和店员在日常工作中可能遇到的消费者疑问,提供了标准化的学术应答信息,以便其根据自身判断进行专业回应。拜访形式以一对一或一对二的小范围沟通为主,平均时长5至10分钟,注重信息的准确传达与现场反馈收集。',
f'学术信息传递方面,本次拜访着重讲解{product_short}作为双歧杆菌三联活菌制剂的菌株组合特性,涵盖活菌数量、肠道定植能力、储存条件及适用人群等维度与同类产品的差异比较。此外,围绕药店人员日常面对的消费者常见问题,整理了标准化学术应答参考,便于其结合专业判断予以回应。拜访采取一对一至一对二的小规模交流形式,单次沟通时长约5至10分钟,侧重信息精准传递及现场意见收集。',
f'就学术推广的具体内容而言,本次重点围绕{product_short}作为双歧杆菌三联活菌制剂的菌株组合优势展开,从活菌数量、肠道定植能力、储存条件及临床适用人群等方面与常见益生菌产品进行了对比说明。同时,针对药店一线人员在日常服务中可能遇到的消费者咨询,提供了规范化的学术应答要点,以便其依据自身专业判断作出回应。沟通形式以小范围交流(一对一或一对二)为主,每次约5至10分钟,重视信息的准确传达和现场反馈的收集。',
f'本次学术推广的核心内容聚焦于{product_short}作为双歧杆菌三联活菌制剂的菌株组合特征,从活菌数量、肠道定植能力、储存条件及适用人群等角度,与市面常见益生菌产品进行了差异分析。同时,针对药师及店员在日常工作中可能面对的消费者疑问,梳理了标准化的学术应答素材,方便其基于自身判断进行专业回复。拜访交流以一对一或一对二的小范围形式开展,平均时长控制在5至10分钟,注重信息传达的准确性与现场反馈的及时收集。',
]
# === 第三段:目标达成情况 (4种表述) ===
good_count = max(6, total_count - 4)
neutral_count = total_count - good_count
result_variants = [
f'从目标达成情况来看,本次拜访完成了全部{total_count}家门店的学术覆盖任务,执行率达100%。在反馈效果方面,有{good_count}家门店的药师或店员对介绍内容表现出兴趣或无明显异议,信息接受度较好;另有{neutral_count}家门店反馈较为中性,其中部分人员明确表示更倾向了解竞品信息,或未给出积极反馈。整体来看,学术信息传递顺畅,多数门店接受度良好。',
f'就目标达成而言,本次{total_count}家门店的学术覆盖任务已全部完成,执行率100%。从反馈效果看,{good_count}家门店的药师或店员对所介绍内容表现出一定兴趣或未提出异议,信息接收情况较好;其余{neutral_count}家门店的反馈相对中性,部分人员表示更关注竞品信息或未作出明确反馈。总体而言,学术信息传递过程顺利,大部分门店的接受度良好。',
f'在目标达成方面,全部{total_count}家门店的学术覆盖工作均已落实,完成率100%。反馈层面,{good_count}家门店的药师或店员对讲解内容表现出兴趣或无异议,信息接受度较为理想;另外{neutral_count}家门店的反馈偏中性,个别人员倾向于了解竞品信息或未给予明确反馈。综合来看,学术信息传递较为顺畅,多数门店展现出良好的接受度。',
f'从完成情况来看,本次{total_count}家门店的学术覆盖任务已全部达成,执行完成率100%。在反馈效果方面,{good_count}家门店的药师或店员对介绍内容表现出兴趣或未提出异议,信息接受度良好;其余{neutral_count}家门店反馈较为中性,部分人员明确表示倾向了解竞品信息或未给出积极反馈。整体而言,学术信息传递顺畅,多数门店的接受度达到预期。',
]
# === 第四段:备注与建议 (4种表述) ===
note_variants = [
'需要指出的是,本次拜访未发放任何学术资料或产品提示物,信息主要依靠口头传递。后续可考虑在类似学术介绍中补充简要的对比性资料,以增强信息留存效果。针对反馈中性的门店,建议安排二次学术沟通,进一步澄清产品学术信息,不改变本次仅作介绍的工作性质。',
'值得一提的是,本次拜访过程中未配发学术资料或产品提示物,相关信息主要通过口头交流完成。今后可考虑在类似学术推广中增加简要的对比性材料,以提升信息留存效果。对于反馈中性的门店,建议择机进行二次学术沟通,进一步补充产品学术信息,同时维持本次仅作学术介绍的工作定位。',
'此外,本次拜访未携带学术资料或产品提示物,信息传递以口头方式为主。后续在类似学术推广活动中,可考虑增加简明的对比性参考资料,以提升信息的留存与传播效果。针对反馈偏中性的门店,建议适时安排二次学术跟进,进一步补充产品学术信息,同时保持本次仅限于学术介绍的工作性质不变。',
'另外需说明的是,本次活动未发放学术资料或产品提示物,信息传达主要依赖口头沟通。后续类似学术推广中可考虑配备简要的对比性资料,以提高信息留存效果。对于反馈中性的门店,建议安排二次学术沟通以进一步明确产品学术信息,但整体工作性质仍以学术介绍为主,不做变更。',
]
# 随机选择各段表述
return [
random.choice(open_variants),
random.choice(content_variants),
random.choice(result_variants),
random.choice(note_variants),
]
def generate_docx(visit_data):
"""按模板 XML 级别精确生成 .docx 文件"""
doc = Document()
# ===== 页面设置 (US Letter 横向) =====
section = doc.sections[0]
section.page_width = Emu(10058400) # 11.0"
section.page_height = Emu(7772400) # 8.5"
section.top_margin = Emu(914400) # 1.0"
section.bottom_margin = Emu(914400)
section.left_margin = Emu(914400)
section.right_margin = Emu(914400)
# ===== Normal 样式: 匹配模板 - 宋体 11pt, 行距 1.079, 段后 8pt =====
# 模板 Normal 使用主题字体: minorHAnsi→Times New Roman, minorEastAsia→宋体
style = doc.styles['Normal']
style.font.name = 'Times New Roman'
style.font.size = Pt(11)
style.paragraph_format.line_spacing = 1.079
style.paragraph_format.space_after = Pt(8)
rPr = style.element.get_or_add_rPr()
rFonts = parse_xml(f'<w:rFonts {nsdecls("w")} w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体"/>')
# 移除旧的 rFonts (如果有的话)
for old_rf in list(rPr.findall(f'{{{WML_NS}}}rFonts')):
rPr.remove(old_rf)
rPr.insert(0, rFonts)
# 字号
for old_sz in list(rPr.findall(f'{{{WML_NS}}}sz')):
rPr.remove(old_sz)
sz = parse_xml(f'<w:sz {nsdecls("w")} w:val="22"/>')
rPr.append(sz)
szCs = parse_xml(f'<w:szCs {nsdecls("w")} w:val="22"/>')
rPr.append(szCs)
# ===== 提取数据 =====
region = visit_data.get('region', '')
district = visit_data.get('district', '')
person = visit_data.get('personInCharge', '')
visit_date_display = visit_data.get('visitDateDisplay', '')
visit_date_full = visit_data.get('visitDateFull', '')
product = visit_data.get('product', '贝飞达双歧杆菌三联活菌肠溶胶囊')
pharmacies = visit_data.get('pharmacies', [])
total_count = visit_data.get('totalCount', len(pharmacies))
total_people = visit_data.get('totalPeople', 0)
photos = visit_data.get('photos', [])
company = visit_data.get('company') or '广西科佐健康管理有限公司'
company_stamp = visit_data.get('companyStamp') or visit_data.get('company') or '广西科佐市场管理有限公司'
month_match = re.search(r'(\d+)年(\d+)月', visit_date_full)
year_month = f'{month_match.group(1)}年{month_match.group(2)}月' if month_match else visit_date_full
date_parts = visit_date_display.split('.')
day_str = date_parts[2] if len(date_parts) > 2 else ''
month_str = date_parts[1] if len(date_parts) > 1 else ''
# =====================================================
# P0: 标题 - 仿宋 22pt 加粗 居中 color=000000 (模板5个run)
# =====================================================
add_multi_run_p(doc, [
('*', FONT_NAME, 22, True, COLOR_BLACK),
('<', FONT_NAME, 22, True, COLOR_BLACK),
(region, FONT_NAME, 22, True, COLOR_BLACK),
('>', FONT_NAME, 22, True, COLOR_BLACK),
('*药店客户拜访清单', FONT_NAME, 22, True, COLOR_BLACK),
], alignment=WD_ALIGN_PARAGRAPH.CENTER)
# P1: 空行 - 两端对齐 缩进 24.1pt, 行距 1.5
add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, indent_pt=24.1, line_spacing=1.5)
# P2-P4: 基本信息 - 仿宋 12pt 加粗 两端对齐 缩进 24.1pt 行距 1.5 color=000000 (模板2个run)
for label, value in [
('服务公司:', company),
('服务省份及地区:', region),
('执行人员:', person),
]:
add_multi_run_p(doc, [
(label, FONT_NAME, 12, True, COLOR_BLACK),
(value, FONT_NAME, 12, True, COLOR_BLACK),
], alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
first_line_indent_pt=24.1, line_spacing=1.5)
# P5-P6: 服务时间 - 仿宋 11pt 加粗 两端对齐 缩进 22.1pt 行距 1.5
for line in [f'服务开展时间:{visit_date_display}', f'表单提交时间:{visit_date_display}']:
add_p(doc, line, font_name=FONT_NAME, size_pt=11, bold=True,
alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
first_line_indent_pt=22.1, line_spacing=1.5)
# P7: 空行 - 两端对齐 缩进 24.0pt, 行距 1.5
add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, indent_pt=24.0, line_spacing=1.5)
# P8: 带空run的空行 - 14pt 加粗 color=000000 (模板中有分页符!)
add_empty_p(doc, has_run=True, font_name=None, size_pt=14, bold=True, color=COLOR_BLACK,
page_break=True)
# P9: "药店客户拜访清单" - 仿宋 14pt 加粗 两端对齐 行距 1.5 color=000000
add_p(doc, '药店客户拜访清单',
font_name=FONT_NAME, size_pt=14, bold=True, color=COLOR_BLACK,
alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
# P10: 介绍段落 - 继承 Normal (11pt) 两端对齐 行距 1.5 (无缩进, 无加粗, 无颜色)
if district:
intro = f'{visit_date_full}期间,本公司针对*<{region}>**<{district}>**<{total_count}>*家药店进行拜访工作,旨在传达产品优势,了解用药反馈,在拜访当中,重点针对*<{product}>*进行学术推广及讲解,现将拜访结果汇报如下:'
else:
intro = f'{visit_date_full}期间,本公司针对*<{region}>*家药店进行拜访工作,旨在传达产品优势,了解用药反馈,在拜访当中,重点针对*<{product}>*进行学术推广及讲解,现将拜访结果汇报如下:'
add_p(doc, intro, font_name=None, size_pt=None, bold=None, color=None,
alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
# P11: "一、药店拜访开展情况总结" - 仿宋 14pt 加粗, 段后 6pt, 行距 1.5
add_p(doc, '一、药店拜访开展情况总结',
font_name=FONT_NAME, size_pt=14, bold=True, color=None,
line_spacing=1.5, space_after_pt=6)
# P12: 统计说明 - 模板使用"列出段落1"样式(10pt 宋体) 缩进 24.0pt 行距 1.5 (模板5个run)
add_multi_run_p(doc, [
(f'此次药店拜访针对*<{total_count}>*家药店、*<', None, None, None, None),
(str(total_people), None, None, None, None),
('>*个', None, None, None, None),
('柜员', None, None, None, None),
(f'开展{year_month}的拜访,项目总拜访次数为*<{total_count}>*次。', None, None, None, None),
], first_line_indent_pt=24.0, line_spacing=1.5)
# P13: "拜访统计表如下:" - 仿宋 12pt 行距 1.5 (模板有 first_line_indent=0, left_indent=0.5")
p13 = add_p(doc, '拜访统计表如下:',
font_name=FONT_NAME, size_pt=12, bold=False, color=None,
line_spacing=1.5)
p13.paragraph_format.first_line_indent = Pt(0)
p13.paragraph_format.left_indent = Inches(0.5)
# P14: 1个空行 - 两端对齐 行距 1.5 (清除段后间距)
p = add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.space_before = Pt(0)
# =====================================================
# 表1: 统计表 (n+2行 x 7列)
# =====================================================
table1 = doc.add_table(rows=len(pharmacies) + 2, cols=7)
table1.alignment = WD_TABLE_ALIGNMENT.CENTER
set_table_columns(table1, COL_WIDTHS_0)
set_table_borders(table1)
set_table_cell_margins(table1)
headers1 = ['序号', '医院药店', '类型', '拜访人数', '产品', '拜访频次', '拜访小计']
for ci, h in enumerate(headers1):
cell = table1.rows[0].cells[ci]
set_cell_vcenter(cell)
# 模板: 仿宋 10.5pt, bold=False, color=None
set_cell_text_and_font(cell, h, font_name=FONT_NAME, size_pt=10.5, bold=False, color=None)
for ri, pharm in enumerate(pharmacies):
row = table1.rows[ri + 1]
values = [
str(ri + 1),
pharm.get('name', ''),
pharm.get('type', '连锁'),
str(len(pharm.get('people', []))),
product,
'1',
'1'
]
for ci, v in enumerate(values):
cell = row.cells[ci]
set_cell_vcenter(cell)
set_cell_text_and_font(cell, v, font_name=None, size_pt=None, bold=None, color=None)
# 总计行
total_row = table1.rows[len(pharmacies) + 1]
for ci in range(7):
set_cell_vcenter(total_row.cells[ci])
set_cell_text_and_font(total_row.cells[0], '总计', font_name=FONT_NAME, size_pt=10.5, bold=False, color=None)
# 列1: 两行文本
cell_total_1 = total_row.cells[1]
cell_total_1.text = ''
p = cell_total_1.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.0
run = p.add_run(f'*<拜访药店数量>*\n{total_count}')
set_run_font(run, font_name=None, size_pt=None, bold=None, color=None)
set_cell_vcenter(cell_total_1)
# 列2-5: 空
for ci in range(2, 6):
set_cell_text_and_font(total_row.cells[ci], '', font_name=None, size_pt=None, bold=None, color=None)
# 列6: 两行文本
cell_total_6 = total_row.cells[6]
cell_total_6.text = ''
p = cell_total_6.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.0
run = p.add_run(f'*<拜访次数>*\n{total_count}')
set_run_font(run, font_name=None, size_pt=None, bold=None, color=None)
set_cell_vcenter(cell_total_6)
# P17: 空行 - 仿宋 12pt (带run) (模板中有分页符!)
add_empty_p(doc, has_run=True, font_name=FONT_NAME, size_pt=12, bold=False, color=COLOR_BLACK,
page_break=True)
# P18: "二、药店拜访开展清单" - 仿宋 14pt 加粗, 段后 6pt, 行距 1.5
add_p(doc, '二、药店拜访开展清单',
font_name=FONT_NAME, size_pt=14, bold=True, color=None,
line_spacing=1.5, space_after_pt=6)
# P19: 1个空行 - 两端对齐 行距 1.5 (清除段后间距)
p = add_empty_p(doc, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY, line_spacing=1.5)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.space_before = Pt(0)
# =====================================================
# 表2: 明细表 (n+1行 x 12列)
# =====================================================
table2 = doc.add_table(rows=len(pharmacies) + 1, cols=12)
set_table_columns(table2, COL_WIDTHS_1)
set_table_borders(table2)
set_table_cell_margins(table2)
headers2 = [
'药店名称', '类型', '拜访人员名称', '拜访人员职称',
'拜访日期', '产品', '拜访目的', '拜访主要内容',
'拜访人员反馈', '平均拜访时长', '学术资料及产品提示物类型(如有)', '拜访效果'
]
for ci, h in enumerate(headers2):
cell = table2.rows[0].cells[ci]
set_cell_vcenter(cell)
# 模板: 仿宋 12pt, bold=False, color=000000
set_cell_text_and_font(cell, h, font_name=FONT_NAME, size_pt=12, bold=False, color=COLOR_BLACK)
for ri, pharm in enumerate(pharmacies):
row = table2.rows[ri + 1]
people = pharm.get('people', [])
people_str = '、'.join(people)
titles = pharm.get('titles', [])
title_str = '、'.join(titles) if titles else '柜员'
values = [
pharm.get('name', ''),
pharm.get('type', '连锁'),
people_str,
title_str,
visit_date_display,
product,
'学术信息传递',
'讲解贝飞达与同类益生菌产品的对比优势。',
'无',
'5-10分钟',
'无',
pharm.get('effect', '优')
]
for ci, v in enumerate(values):
cell = row.cells[ci]
set_cell_vcenter(cell)
set_cell_text_and_font(cell, v, font_name=None, size_pt=None, bold=None, color=None)
# =====================================================
# 分页符 - 确保药店拜访小结从新页面开始,有足够空间放置标题+内容+落款
# =====================================================
add_empty_p(doc, has_run=True, page_break=True)
# =====================================================
# P25: "三、药店拜访小结" - 仿宋 14pt 加粗 左对齐
# 参考模板图片:标题左对齐,下方留白2行
# =====================================================
p25 = add_p(doc, '三、药店拜访小结',
font_name=FONT_NAME, size_pt=14, bold=True, color=None,
alignment=WD_ALIGN_PARAGRAPH.LEFT,
line_spacing=1.5, space_after_pt=0)
p25.paragraph_format.keep_with_next = True
p25.paragraph_format.keep_together = True
# 标题下方空行(匹配模板图片中的留白)
p_gap = add_empty_p(doc, line_spacing=1.5)
p_gap.paragraph_format.space_after = Pt(0)
p_gap.paragraph_format.space_before = Pt(0)
p_gap.paragraph_format.keep_with_next = True
# P26-P29: 小结内容 - 仿宋 12pt color=000000 两端对齐 首行缩进2字符 行距 1.5
# 每次生成时随机改写表述,核心内容不变
summary_paragraphs = generate_summary_paragraphs(
year_month, total_count, total_people, month_str, day_str, product, region
)
for i, s in enumerate(summary_paragraphs):
p = add_p(doc, s, font_name=FONT_NAME, size_pt=12, bold=None, color=COLOR_BLACK,
alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
first_line_indent_pt=24.0, line_spacing=1.5,
space_before_pt=0, space_after_pt=0)
# 保持所有小结段落和签名在一起
p.paragraph_format.keep_with_next = True
p.paragraph_format.keep_together = True
# 落款前空行(与正文保持间距,匹配模板图片)
p_gap2 = add_empty_p(doc, line_spacing=1.5)
p_gap2.paragraph_format.space_after = Pt(0)
p_gap2.paragraph_format.space_before = Pt(0)
p_gap2.paragraph_format.keep_with_next = True
# P31-P33: 签名区 - 仿宋 12pt 左对齐 缩进 行距 1.5 color=000000
# 参考模板图片:落款位于页面右下区域,三行左对齐
SIGNATURE_LEFT_INDENT = Emu(4330700) # ~4.73" 从左侧缩进
# P31: 服务公司
p31 = add_p(doc, f'服务公司:{company_stamp}(盖章)\u00A0',
font_name=FONT_NAME, size_pt=12, bold=False, color=COLOR_BLACK,
alignment=WD_ALIGN_PARAGRAPH.LEFT,
first_line_indent_pt=0, line_spacing=1.5,
space_before_pt=0, space_after_pt=0)
p31.paragraph_format.left_indent = SIGNATURE_LEFT_INDENT
p31.paragraph_format.keep_with_next = True
p31.paragraph_format.keep_together = True
# P32: 活动负责人
p32 = add_multi_run_p(doc, [
('活动负责人:', FONT_NAME, 12, False, COLOR_BLACK),
(person, FONT_NAME, 12, False, COLOR_BLACK),
('\u00A0', FONT_NAME, 12, False, COLOR_BLACK),
], alignment=WD_ALIGN_PARAGRAPH.LEFT,
first_line_indent_pt=0, line_spacing=1.5,
space_before_pt=0, space_after_pt=0)
p32.paragraph_format.left_indent = SIGNATURE_LEFT_INDENT
p32.paragraph_format.keep_with_next = True
p32.paragraph_format.keep_together = True
# P33: 日期
p33 = add_p(doc, f'日期: {visit_date_display}',
font_name=FONT_NAME, size_pt=12, bold=False, color=None,
alignment=WD_ALIGN_PARAGRAPH.LEFT,
first_line_indent_pt=0, line_spacing=1.5,
space_before_pt=0, space_after_pt=0)
p33.paragraph_format.left_indent = SIGNATURE_LEFT_INDENT
p33.paragraph_format.keep_together = True
# =====================================================
# 表3: 照片表 (3行 x 5列) - 放在文档末尾,用于打卡照片
# 模板为3行x5列,无边框(照片直接放置,不显示表格线)
# 使用 page_break_before 让照片表从新页面开始,避免多余空白页
# =====================================================
# 在表格前添加一个段落,设置 page_break_before 属性
p_before_table = doc.add_paragraph()
p_before_table.paragraph_format.page_break_before = True
p_before_table.paragraph_format.space_before = Pt(0)
p_before_table.paragraph_format.space_after = Pt(0)
p_before_table.paragraph_format.line_spacing = 1.0
table3 = doc.add_table(rows=3, cols=5)
set_table_columns(table3, COL_WIDTHS_2)
# 照片表不加边框 - 匹配模板
set_table_cell_margins(table3)
# 设置所有单元格垂直居中 + 清除段落间距
for ri in range(3):
for ci in range(5):
cell = table3.rows[ri].cells[ci]
set_cell_vcenter(cell)
set_cell_text_and_font(cell, '', font_name=None, size_pt=None, bold=None, color=None)
# 放置照片(按模板位置模式:行0全5列, 行1列0-2+列4, 行2)
if photos:
photo_paths = []
for p in photos:
if isinstance(p, str):
if os.path.exists(p):
photo_paths.append(p)
else:
try:
if p.startswith('data:'):
p = p.split(',', 1)[1]
img_data = base64.b64decode(p)
temp_path = os.path.join(os.environ.get('TEMP', '.'),
f'visit_photo_{str(abs(hash(p)))[:8]}.jpg')
with open(temp_path, 'wb') as f:
f.write(img_data)
photo_paths.append(temp_path)
except:
pass
# 照片位置: 前5张放行0, 接着4张放行1的特定列, 剩余放行2
positions = [
(0, 0), (0, 1), (0, 2), (0, 3), (0, 4),
(1, 0), (1, 1), (1, 2), (1, 4),
(2, 0), (2, 1), (2, 2), (2, 3), (2, 4),
]
for pi, (ri, ci) in enumerate(positions):
if pi < len(photo_paths):
try:
cell = table3.rows[ri].cells[ci]
cell.text = ''
p = cell.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.0
run = p.add_run()
run.add_picture(photo_paths[pi], width=Inches(1.5))
except Exception as e:
print(f'插入照片失败: {e}')
# 保存到内存
buffer = io.BytesIO()
doc.save(buffer)
buffer.seek(0)
return buffer
class ExportHandler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def do_POST(self):
if self.path == '/export':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
try:
visit_data = json.loads(body)
buffer = generate_docx(visit_data)
self.send_response(200)
self.send_header('Content-Type',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
self.send_header('Access-Control-Allow-Origin', '*')
filename = 'visit_%s_%sstores.docx' % (
visit_data.get('visitDateDisplay', '').replace('.', '-'),
visit_data.get('totalCount', 0)
)
self.send_header('Content-Disposition', 'attachment; filename="%s"' % filename)
self.end_headers()
self.wfile.write(buffer.getvalue())
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps({'error': str(e)}).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
print('[%s] %s' % (self.log_date_time_string(), args[0]))
if __name__ == '__main__':
print('导出服务启动在 http://127.0.0.1:%d/export' % PORT)
server = HTTPServer(('127.0.0.1', PORT), ExportHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
server.shutdown()