Spaces:
Running
Running
File size: 40,716 Bytes
3a229b3 8f49b74 3a229b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 | """
药店拜访清单 .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() |