ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Jinja2与WeasyPrint的分散供养照料护理PDF批量生成及回读校验

2026/9/17 13:57:17 拓冰建站 浏览量
基于Jinja2与WeasyPrint的分散供养照料护理PDF批量生成及回读校验 简介《青白江区为分散供养城乡特困人员购买照料护理服务实施方案2020年修订》是一份民政领域政府购买服务规范性文件面向基层民政工作者、镇街道经办人员、社会组织与社工机构从业者以及关注特困人员救助供养政策的研究者。文件围绕购买主体、受托方、购买内容、服务对象、购买标准、经费使用、资金拨付结算、协议签订与督查考核逐项作出规定并附照料护理服务协议参考模板、服务人员名单及金额表、工作台账、服务明细表与测评考核评估表等附件明确探视、陪同看病、住院陪护等必购服务及按自理能力分档的补助标准。整包仅1个PDF文件约397KB篇目完整、条款清晰可直接用于政策解读、方案起草与台账模板参照。目前已有394人学习下载。1. 一份分散供养照料护理服务实施方案 PDF本质是模板加数据的文档工程经办岗最怕的不是写方案是同一份方案改两百遍。为分散供养城乡特困人员购买照料护理服务的实施方案正文结构高度固定真正变化的只有姓名、乡镇村、护理等级、服务包、计费标准和起止时间。交给 Word 手工套改迟早出现金额与服务包对不上、附件漏页、几个人手里三个版本的情况。换个视角看它是一次典型的文档工程问题一份结构化台账、一个 Jinja2 模板、一个 PDF 渲染器就能把照料护理服务实施方案批量产出而且每一份都能回读校验。这套做法适合需要成批出方案、出协议、出服务记录的经办与开发同学也适合正在把存量纸质档案迁成可检索数据的人。后面按「先定字段、再渲染、再回读、最后接进归档」的顺序走一遍中间给出可直接抄的模型定义、模板片段和命令。2. 把照料护理服务实施方案拆成可校验的数据模型2.1 从版式反推三类实体对象、服务包、协议一份实施方案 PDF版面上大致是封面、对象基本情况、照料护理服务项目清单、服务标准与费用、双方约定、签署页。肉眼看着是自然语言拆到字段层面其实只有三类实体在流动对象是谁、服务包做什么、每月几次、每次多少钱、协议编号多少、从哪天到哪天、由谁执行。版式里的每句话要么直接取某个字段要么是几个字段拼出来的模板句。拆实体最容易踩的坑是把分散供养和集中供养塞进同一张表再用一堆可空字段兜着。两种方式的差异不在措辞而在字段集合分散供养要记录照料服务人、上门频次、居住地址集中供养偏重机构和床位信息。稳妥的拆法是共用对象主表把差异部分下沉成子表模板按供应方式分支渲染而不是在一张宽表里到处判空。服务包我一般单独抽成字典表不写死在模板里。服务项目名称、单价、频次会随年度调整写死在模板意味着每次调价都要改模板、重跑排版回归抽成字典表以后模板只负责循环渲染调价只动数据。对象表、服务包字典表、协议表的关联键统一用对象编号避免拿姓名做关联——重名在乡镇一级并不罕见。2.2 用 Pydantic 定义一份可校验的台账模型台账从 Excel 或业务库导出来后第一道关卡就是类型和取值校验。用 Pydantic 的好处是校验规则跟字段定义写在一起导出、入库、渲染三条路径共用同一个模型。from datetime import date from decimal import Decimal from enum import Enum from pydantic import BaseModel, Field, field_validator class CareLevel(str, Enum): full 全护理 half 半护理 self_care 自理 class SupplyMode(str, Enum): dispersed 分散供养 centralized 集中供养 class ServiceItem(BaseModel): name: str Field(..., description照料护理服务项目名称需命中服务包字典表) freq_per_month: int Field(..., ge0, le60, description每月服务频次) unit_price: Decimal Field(..., ge0, description单次计费标准元) def monthly_amount(self) - Decimal: return self.unit_price * self.freq_per_month class CarePlan(BaseModel): plan_no: str Field(..., patternr^[A-Z]{2}\d{8}$) name: str Field(..., min_length2, max_length20) id_card_tail: str Field(..., min_length4, max_length4) # 只留后四位 town: str village: str supply_mode: SupplyMode SupplyMode.dispersed care_level: CareLevel caregiver: str Field(..., description照料服务人分散供养时必填) service_items: list[ServiceItem] Field(..., min_length1) start_date: date end_date: date field_validator(end_date) classmethod def check_period(cls, v, info): start info.data.get(start_date) if start and v start: raise ValueError(协议结束日期必须晚于开始日期) return v property def total_monthly(self) - Decimal: # 月合计按服务项逐条累加不用外部传入避免台账与模板两套算法 return sum((i.monthly_amount() for i in self.service_items), Decimal(0))逐项说明。plan_no用正则钉死格式编号是后续对账和去重的唯一键格式一旦放开回读校验就没法定位。id_card_tail只保留后四位完整证件号不进台账、不进模板、不进 PDF这是排版阶段就该守住的边界。monthly_amount和total_monthly都做成计算属性而不是存储字段原因是金额只允许存在一处真相单价乘频次。如果台账里另外存一个合计列迟早出现导出的 Excel 合计与模板算出来的合计对不上。end_date的校验器依赖start_date所以字段顺序不能随意调整这也是 Pydantic 里做跨字段校验时最常见的翻车点。2.3 关键字段的类型与校验规则对照表字段类型约束不通过时的处理plan_nostr^[A-Z]{2}\d{8}$同批内唯一拒绝入库写 rejected.jsonlsupply_modeenum分散供养 / 集中供养拒绝入库care_levelenum全护理 / 半护理 / 自理拒绝入库service_itemslist至少 1 项名称须命中服务包字典表拒绝入库freq_per_monthint0 ≤ n ≤ 60拒绝入库unit_priceDecimal≥ 0且等于字典表当前年度单价记警告转人工确认start_date / end_datedate结束晚于开始周期不超过 12 个月拒绝入库caregiverstr非空分散供养时必填拒绝入库id_card_tailstr恰好 4 位拒绝入库区分拒绝入库和记警告是有意的编号、等级、日期这类错了就是错重跑也不会有别的结果单价不一致往往是字典表年度没同步属于数据版本问题硬拦会把整批卡死记警告更合适。2.4 台账落库建表与批量导入台账建议落一份本地 SQLite原因是批量渲染、回读校验、差异比对都要反复读同一批数据落库比每次解析 Excel 稳。CREATE TABLE care_plan ( plan_no TEXT PRIMARY KEY, name TEXT NOT NULL, id_card_tail TEXT NOT NULL, town TEXT NOT NULL, village TEXT NOT NULL, supply_mode TEXT NOT NULL CHECK (supply_mode IN (分散供养,集中供养)), care_level TEXT NOT NULL CHECK (care_level IN (全护理,半护理,自理)), caregiver TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, row_version INTEGER NOT NULL DEFAULT 1, rendered_sha256 TEXT, CHECK (end_date start_date) ); CREATE TABLE care_plan_item ( plan_no TEXT NOT NULL REFERENCES care_plan(plan_no), seq INTEGER NOT NULL, item_name TEXT NOT NULL, freq_per_month INTEGER NOT NULL CHECK (freq_per_month BETWEEN 0 AND 60), unit_price NUMERIC NOT NULL CHECK (unit_price 0), PRIMARY KEY (plan_no, seq) );row_version和rendered_sha256是给后面的版本比对留的口子台账改一次版本加一PDF 渲染完把文件摘要写回去任何一次这份方案是不是改过的追问都能立刻回答不用靠文件修改时间猜。3. 用 Jinja2 加 WeasyPrint 把台账渲染成实施方案 PDF3.1 模板分层主模板、区块、样式表模板目录我一般拆成三层plan.html.j2只管页面顺序sections/下按对象情况、服务清单、费用约定切区块plan.css集中管排版。这么拆是为了应对方案版本变化——有的批次要多加一段服务承诺只改主模板的一行 include不动其他区块。样式里必须显式声明page页边距、页眉页脚、页码都靠它不要指望渲染器给默认值。中文字体名要写成系统里真实存在的名字写完用fc-list :langzh确认一次别凭印象填。3.2 服务清单与费用汇总的模板片段{# templates/plan.html.j2 #} section classperson h2一、对象基本情况/h2 table classkv trth姓名/thtd{{ plan.name }}/tdth对象编号/thtd{{ plan.plan_no }}/td/tr trth供养方式/thtd{{ plan.supply_mode.value }}/td th护理等级/thtd{{ plan.care_level.value }}/td/tr trth所在乡镇/thtd{{ plan.town }}{{ plan.village }}/td th照料服务人/thtd{{ plan.caregiver }}/td/tr /table /section section classitems h2二、照料护理服务项目/h2 table classgrid thead trth序号/thth服务项目/thth频次(次/月)/thth单价(元)/thth月金额(元)/th/tr /thead tbody {% for item in plan.service_items %} tr td{{ loop.index }}/td td{{ item.name }}/td td{{ item.freq_per_month }}/td td{{ %.2f|format(item.unit_price) }}/td td{{ %.2f|format(item.monthly_amount()) }}/td /tr {% endfor %} /tbody tfoot trtd colspan4合计/tdtd{{ %.2f|format(plan.total_monthly) }}/td/tr /tfoot /table /section几点说明。loop.index让序号跟着行数走不用在数据里存序号避免删行后编号断档。金额统一用%.2f|format(...)格式化Decimal 直接输出会带上下文精度看起来像120.000这种。合计行放在tfoot而不是普通行是为了配合thead { display: table-header-group }表格跨页时表头和合计的定位都稳。模板里不要出现|safe乡镇名、村名、照料服务人姓名都可能带、引号这类字符转义交给 Jinja 的 autoescape别手动关。3.3 渲染命令与中文排版参数from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape from weasyprint import HTML, CSS env Environment( loaderFileSystemLoader(templates), autoescapeselect_autoescape([html, j2]), # 地址含 时不转义会破版 trim_blocksTrue, lstrip_blocksTrue, ) tpl env.get_template(plan.html.j2) def render(plan, out_dir: Path Path(out)) - Path: html tpl.render(planplan) safe_town plan.town.replace(/, _).replace( , ) target out_dir / f{plan.plan_no}_{safe_town}_{plan.name}.pdf out_dir.mkdir(parentsTrue, exist_okTrue) HTML(stringhtml, base_url.).write_pdf( target, stylesheets[CSS(templates/plan.css)], presentational_hintsTrue, ) return targetselect_autoescape([html, j2])必须显式传默认规则不一定认.j2后缀漏掉就等于关掉了转义。base_url.决定相对路径的解析基准模板里引用的公章图片、水印图片靠它定位用string而不是filename渲染时尤其要指定。presentational_hintsTrue让渲染器尊重 HTML 里少量表现属性代价是样式优先级会变如果 CSS 写得很干净可以关掉。样式侧最关键的几行page { size: A4; margin: 22mm 18mm; bottom-center { content: 第 counter(page) 页 / 共 counter(pages) 页; } } body { font-family: Noto Sans CJK SC, Source Han Sans SC, sans-serif; font-size: 10.5pt; line-height: 1.6; } .grid { width: 100%; border-collapse: collapse; } .grid tr { break-inside: avoid; } .grid thead { display: table-header-group; }3.4 批量生成与文件命名规范from concurrent.futures import ThreadPoolExecutor def load_plans(pathledger.jsonl): # 逐行校验单条坏数据不会带崩整批 with open(path, encodingutf-8) as f: for line in f: line line.strip() if line: yield CarePlan.model_validate_json(line) plans list(load_plans(ledger.jsonl)) with ThreadPoolExecutor(max_workers4) as pool: # CPU 密集核数即可 for pdf_path in pool.map(render, plans): print(pdf_path)逐行读取而不是一次性json.load是因为台账里难免混进几条脏数据逐行能让校验失败的记录单独落到rejected.jsonl其余照常渲染。线程数别盲目开大PDF 渲染吃 CPU 和内存4 到 8 之间通常就是拐点再往上只会互相抢内存。文件名里要主动替换/、空格、全角括号否则在部分文件系统上会直接报错对象编号放在最前面是为了后续按编号排序和检索。3.5 三个高频排版坑及处理方式现象根因处理服务清单被切断第二页没有表头表格行跨页表头未重复tr { break-inside: avoid }加thead { display: table-header-group }部分汉字渲染成方块运行环境没有装 CJK 字体fc-list :langzh确认CSS 字体名写成实际族名封面后多出一页空白封面容器高度正好等于页面高度封面高度改为calc(100% - 1px)或改用break-after: page页脚没有页码没用page的页码计数器用counter(page)和counter(pages)写进bottom-center4. 生成完的实施方案 PDF 怎么做回读校验4.1 用 pdfplumber 抽取关键字段渲染不是终点能读回来才算闭环。回读的目的是确认数据经过模板、字体、分页之后落在纸面上的值跟台账一致这一步能挡住绝大多数模板错误。import pdfplumber, re FIELD_PATTERNS { plan_no: r对象编号[:]\s*(\S), care_level: r护理等级[:]\s*(\S), total: r合计[:]?\s*([\d.]), } def readback(pdf_path: str) - dict: with pdfplumber.open(pdf_path) as pdf: text \n.join((p.extract_text() or ) for p in pdf.pages) pages len(pdf.pages) got {k: (m.group(1) if (m : re.search(v, text)) else None) for k, v in FIELD_PATTERNS.items()} got[pages] pages return got正则里的[:]同时兼容半角和全角冒号中文字体下两种写法都可能出现只写半角会在部分批次里静默失配。extract_text()返回None的情况要兜住空页或纯图片页会返回None直接拼接会抛异常。页数单独统计是为了做页数异常这种粗粒度但有效的兜底检查。4.2 三方一致性校验源数据、PDF 回读、汇总表校验项来源 A来源 B不一致时的动作对象编号台账 plan_noPDF 回读 plan_no重渲染一次二次失败转人工月合计金额台账 total_monthlyPDF 回读 total阻断发布回查服务包单价护理等级台账 care_levelPDF 回读 care_level阻断发布页数模板预期区间 3–6 页PDF 实际页数记警告附上文件路径待抽查金额和等级这两项我按阻断处理原因是它们直接决定后续结算口径错一份就是真金白银的差错页数偏差往往只是内容长短抽查即可。4.3 校验报告落盘与失败重跑import json, hashlib from pathlib import Path def sha256_of(path: Path) - str: h hashlib.sha256() with open(path, rb) as f: for chunk in iter(lambda: f.read(1 20), b): h.update(chunk) return h.hexdigest() def verify(plan, pdf_path: Path, report_pathverify.jsonl): got readback(str(pdf_path)) errors [] if got[plan_no] ! plan.plan_no: errors.append((plan_no, plan.plan_no, got[plan_no])) if got[care_level] ! plan.care_level.value: errors.append((care_level, plan.care_level.value, got[care_level])) if abs(float(got[total] or -1) - float(plan.total_monthly)) 0.005: errors.append((total, str(plan.total_monthly), got[total])) record { plan_no: plan.plan_no, sha256: sha256_of(pdf_path), ok: not errors, errors: errors, } with open(report_path, a, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n) return record比对金额时用0.005的容差而不是直接相等因为回读拿到的是字符串转出来的浮点数和 Decimal 直接比会因二进制表示产生微小偏差卡死一批本来正确的文件。报告按行追加 JSON好处是重跑时能保留历史记录用plan_no去重就能看出同一份方案被重试了几次。sha256顺手算出来写进报告后面做版本比对直接用。失败重跑保持幂等以plan_no为主键覆盖输出文件而不是生成带时间戳的新文件名否则跑三轮就会在输出目录里堆三份同名方案归档时分不清哪份是最终版。5. 进阶把实施方案 PDF 接进归档与工单流水线5.1 版本化摘要进清单改动可追溯归档目录里放一份manifest.json把每份 PDF 的编号、摘要、模板版本、生成时间写进去一次生成一次追加import json, hashlib from datetime import datetime, timezone def append_manifest(plan, pdf_path, template_verv3, manifestmanifest.json): entry { plan_no: plan.plan_no, file: pdf_path.name, sha256: sha256_of(pdf_path), template_version: template_ver, generated_at: datetime.now(timezone.utc).isoformat(timespecseconds), } data [] try: data json.loads(open(manifest, encodingutf-8).read()) except FileNotFoundError: pass data.append(entry) with open(manifest, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) return entrytemplate_version这一列比摘要更有用摘要变了只能说明文件变了模板版本才能解释为什么变。台账数据没动、模板从 v3 升到 v4整批文件的摘要都会变这时候要能一眼分辨是内容改了还是版式改了否则每次版式微调都会被当成数据事故来查。校验清单时用sha256sum -c的思路核对一遍即可# 从 manifest 生成校验清单与归档目录里的实际文件比对 python -c import json for e in json.load(open(manifest.json, encodingutf-8)): print(e[sha256], , e[file]) archive.sha256 sha256sum -c archive.sha256 | grep -v : OK$5.2 一份台账渲染多种单据减少口径分叉台账已经是唯一真相服务记录、月度结算单、协议变更确认函都可以从同一份数据渲染只是换模板TEMPLATES { plan: (plan.html.j2, 实施方案), record: (record.html.j2, 服务记录), settle: (settle.html.j2, 月度结算单), } def render_all(plan): for kind, (tpl_name, _) in TEMPLATES.items(): tpl env.get_template(tpl_name) html tpl.render(planplan) target Path(out) / kind / f{plan.plan_no}_{kind}.pdf target.parent.mkdir(parentsTrue, exist_okTrue) HTML(stringhtml, base_url.).write_pdf(target, stylesheets[CSS(templates/plan.css)])服务记录的模板里多一个周期性字段服务日期、服务人签字、对象或家属确认渲染时按freq_per_month展开成若干行空位待填。结算单则只取total_monthly和协议周期算出本期应付。三份单据共用一个total_monthly计算属性意味着结算金额和实施方案上的金额不可能对不上——这比事后做三方对账省事得多。按类型分目录out/plan/、out/record/、out/settle/而不是混在一个目录是为了让归档和权限控制有落点实施方案对外提供服务记录涉及签字件通常只做内部留档。归档时把manifest.json和archive.sha256一起放进去之后再被问这份照料护理服务实施方案到底改没改过一条摘要比对就能回答。本文还有配套的精品资源点击获取