Spaces:
Running
Running
Download export_server.py from data-learner/pharmacy: direct link, hf CLI and curl.
- Browser
- Download file 40.7 kB
-
https://huggingface.co/spaces/data-learner/pharmacy/resolve/main/export_server.py
- Command line
-
hf download hf://spaces/data-learner/pharmacy/export_server.py
-
curl -L -o export_server.py https://huggingface.co/spaces/data-learner/pharmacy/resolve/main/export_server.py
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() |