1. 接种门诊的日常工作里,到底缺了什么
1.1 Excel登记表的三个致命问题
我在基层接种门诊蹲过一段时间,第一次看到接种日当天的场景时,最大的感受不是忙,而是乱。医生桌上摆着一摞接种本,手边是一台开着Excel的旧电脑,屏幕上密密麻麻全是儿童姓名和接种日期。每来一个孩子,医生要先翻接种本找上次记录,再去Excel里对比一遍,最后还要翻免疫程序表算下一针该打什么。这套流程如果只有十几个孩子还能扛,一旦遇上集中接种日,上百个孩子挤在走廊里,出错的概率几乎是必然的。
儿童预防接种信息管理系统要解决的,就是这些问题。动手开发之前,我把门诊实际的痛点列了一遍,发现可以归纳成三个致命问题。第一,儿童档案散落在不同版本的Excel文件里,有的在电脑A,有的在电脑B,一个孩子多条记录靠人工维护,记录错位、重复建档都非常常见。第二,疫苗批号和效期靠手工抄写,哪个批次快过期了没人主动提醒,过期疫苗的存在本身就是底线问题。第三,逾期未种的孩子缺少主动提醒机制,全靠家长自己记着,不少孩子就这样漏了针。
1.2 系统要承接的业务闭环
想明白痛点之后再去做系统,方向就不会跑偏。一个完整的接种业务闭环,在我看来由四个小闭环组成。档案闭环解决的是孩子的基本信息从建档到更新维护的问题;计划闭环解决的是下一次该打什么、什么时候打的问题;库存闭环解决的是疫苗入库、出库、损耗、效期的联动问题;反馈闭环解决的是不良反应记录和随访的问题。
这四个闭环环环相扣。假如库存模块没做好,计划排得再好,医生到了登记台才发现疫苗缺货,整个流程照样卡住。假如档案模块没做好,哪怕免疫程序算法写得再漂亮,排程结果也可能因为出生日期录入错误而全盘出错。这套基于Python的管理系统,从头到尾都是在为这四个闭环服务,Django和Flask两个框架的搭配,本质上也是为了把这些闭环拆得足够清楚。
2. 为什么用Python,以及Django和Flask怎么分工
2.1 Python在这个场景里的天然优势
最初立项时也考虑过Java和PHP,最终选型Python,是一个考虑再三的决定。基层业务系统的数据量并不大,高峰时期几千个儿童档案、几万条接种记录,完全不需要分布式架构,真正复杂的是业务规则。免疫程序表里面几十种疫苗、上百个剂次,每个剂次有适用月龄、最短间隔、补种规则,这些规则用Python表达起来非常接近业务语言,医生提需求时怎么说,工程师就能怎么翻译成代码。
另外一个现实原因是可以快速交付。Django自带Admin后台、用户认证、ORM和迁移机制,这些能力在业务系统开发里几乎是必需品,如果全部从头写一遍,开发周期至少翻一倍。基层门诊的信息化预算不多,项目周期往往卡得很紧,选Python这一套可以让我在短时间内交付一个能真正跑起来的版本,后面再根据医生的反馈逐步迭代。
2.2 Django负责核心业务与管理端
我在这个项目里的分工方式很明确:Django负责系统的主体,包括儿童档案、接种记录、疫苗库存、用户权限这些核心模块。Django的Admin后台直接服务管理人员,疫苗字典、免疫程序表、单位信息这些基础数据的日常维护,管理人员自己就能在后台操作,不需要每次改动都找开发人员写一条SQL。
Django的模型设计也帮我省了很多事。比如儿童档案和接种记录之间是一对多关系,用外键关联之后,查询某个孩子的完整接种史只需要一次ORM查询。权限系统同样是开箱即用的,医生、护士、管理员三种角色各分配不同的权限,医生可以登记接种,护士可以操作库存,管理员可以维护基础数据和查看统计报表。做这类业务系统,Django的成熟度是值得信任的。
2.3 Flask承担轻量接口与报表服务
很多人会问,已经有Django了,为什么还要引入Flask?这不是炫技,也不是追求架构的完整性,而是有一些模块放在Flask里确实更合适。我的拆分原则是:需要全局状态、涉及复杂权限和事务的操作放Django;独立性强、不需要Django中间件和模板引擎的服务放Flask。
实际落到项目里,Flask承担了三类工作。第一类是Excel报表导出,接种率统计、逾期名单、疫苗出入库明细,这些报表逻辑相对独立,用Flask写一个轻量接口直接生成文件。第二类是到期提醒推送,这是一个定时任务,每天扫描到期儿童后发送短信或公众号消息,单独跑在Flask服务里不会影响主业务。第三类是给第三方设备提供查询接口,比如对接身份证读卡器厂商的查询服务,Flask的轻量接口开发起来速度快得多。
两个框架共用一个MySQL数据库。Django的模型是权威定义,所有的表结构变更都通过Django迁移来管理;Flask这边使用SQLAlchemy连接同一套数据库,核心表只做读取,只有推送日志这类独立表允许Flask直接写入。这样的约定避免了两个框架同时改表结构导致的血案。
3. 面向医生的功能设计:每个界面都为了省一次查找
3.1 门诊工作台:把今天的活儿提前列好
医生登录系统后看到的第一个页面,我花了很多心思。工作台的设计原则不是展示数据,而是告诉医生今天该干什么。很多业务系统喜欢把工作台做成信息面板,放一堆统计数字、图表、滚动公告,看起来内容丰富,实际上医生根本不会多看。医生每天面对的是具体的人,不是宏观的数据。
所以我把工作台做成三块清单。第一块是今日预约列表,按预约时间排序,状态实时更新。第二块是今日到期接种名单,这是最重要的模块,系统根据免疫程序和儿童出生日期,在每天凌晨计算出当天到期应种的儿童,早晨一上班医生就能看到名单。第三块是库存预警列表,低于安全库存或者近效期的疫苗品种直接列在工作台上,不用医生专门去库存模块翻一遍。
一个值得分享的细节是,到期名单里的每个儿童都带一个快捷入口,点击直接跳转到接种登记页,自动填充该儿童的档案信息和本次应种的疫苗剂次。这个交互设计看起来不起眼,但在一天上百个儿童的接诊量下,省掉的那一次查找动作,累积起来是非常可观的。
3.2 快速建档与登记
儿童预防接种系统的第一步是建档,这块操作频率虽不高,但质量直接影响后续所有计算。我集成了身份证读卡器,把儿童姓名、性别、出生日期、证件号码直接读取出来填充到表单里,医生只需要补充监护人和联系方式。新生儿还没有身份证号的场景也考虑到了,使用出生医学证明编号作为临时唯一标识,等家长后续补录身份证号时再更新。
建档之外,登记功能考虑的是速度。接种日当天,医生扫描接种本上的条形码就能调出儿童档案,不需要输入姓名拼音或者身份证号。这里有一个小经验:条形码打印质量会影响扫码速度,普通的A4纸打印容易褶皱,建议用不干胶标签纸打印,贴在接种本封面上,一贴能用好几年。
3.3 自动排程:免疫程序表驱动的下一针计算
这个模块是整个系统最核心的算法模块,也是当初说服医生把Excel换掉的关键功能。免疫程序表是一张规则表,里面每条记录包含疫苗名称、剂次序号、适用月龄区间、最短间隔天数。以乙肝疫苗为例,第1剂出生时接种,第2剂满1月龄,第3剂满6月龄,第2剂和第1剂之间至少间隔28天。
系统不是把规则写死在代码里,而是把免疫程序表建成数据库表,管理员可以在后台维护。排程算法很直接:系统遍历程序表里该儿童可能涉及的全部剂次,先过滤掉已经接种的记录,再根据当前日期和儿童出生日期计算每个剂次的应种日期。判断"今天能不能打"有两条硬性标准,一是儿童当前月龄是否落在该剂次的适用区间内,二是距离上一剂次的天数是否满足最短间隔,两个条件同时满足才允许接种。
代码实现也不复杂:
def get_due_vaccines(child, today): due_items = [] for schedule in Schedule.objects.filter(vaccine__is_active=True).order_by("dose_no"): if Vaccination.objects.filter(child=child, vaccine=schedule.vaccine, dose_no=schedule.dose_no).exists(): continue if schedule.min_age_months and child.age_months(today) < schedule.min_age_months: continue if schedule.min_interval_days: last_dose = Vaccination.objects.filter( child=child, vaccine=schedule.vaccine ).order_by("-vaccinated_date").first() if last_dose and (today - last_dose.vaccinated_date).days < schedule.min_interval_days: continue due_items.append(schedule) return due_items这段逻辑要特别注意补种场景。很多儿童不是按标准月龄接种的,比如因为生病、外出等原因错过了某剂次。排程时如果只按年龄判断,就会出现一个9月龄的孩子查不到6月龄该种的疫苗。所以程序表里必须预留补种规则,每个疫苗剂次除了适用月龄,还要维护最晚接种月龄,超出最晚月龄仍然未种就进入逾期名单。
3.4 疫苗库存联动:扫码出库与效期预警
疫苗和普通药品最大的不同是每一支都要能追溯到最终接种的儿童。系统在接种登记页面集成了扫码功能,医生扫疫苗包装上的追溯码,系统自动识别疫苗名称、批号、有效期,并完成出库操作。接种记录里同时写入批号和有效期,这条记录同时满足业务需求和事后追溯需求。
库存模块每天自动执行两个检查。第一是近效期检查,三个月内到期的批次会在工作台生成预警提示。第二是低库存检查,当前库存低于安全库存线的疫苗品种会出现在补货建议列表里。这里有一个批次拆分的关键细节:同一批号的疫苗如果有效期不一致,必须拆分成不同的批次来管理,不能因为是同一个批号就混在一条库存记录里。实际业务里确实出现过同一批号不同效期的情况,混在一起之后效期管理完全失控。
3.5 电子知情同意与禁忌筛查
接种前医生必须完成一项重要工作:问询儿童的健康状况,确认没有接种禁忌。系统把问询项做成了结构化的电子表单,包括近期有无发热、过敏史、免疫缺陷、神经系统疾病等条目。医生逐项确认,有异常情况的标记下来,系统会拦截该疫苗的接种登记,提示医生进行进一步评估。
知情同意书的电子化是很多医生提出的需求。以前纸质知情同意书需要家长签字后扫描存档,归档率低还占空间。系统实现电子签名之后,家长在签字板上签名,签名内容和时间一起存入数据库,同时记录操作医生账号。一旦出现接种纠纷,这条记录就是最直接的证据链,医生当时问了什么、家长答了什么,所有信息都可回溯。
4. 核心数据模型设计:五张关键表的字段与关系
4.1 儿童档案表
儿童档案是系统的基础数据,字段设计上我坚持一个原则:只要和接种计算有关的字段,必须独立存储,不能塞进备注。姓名、性别、出生日期、监护人姓名、联系电话、证件类型和证件号码是必填项,出生日期尤其重要,所有排程计算都依赖这个字段。
这个字段需要注意一个坑:存储时统一用date类型,不要存带时间的datetime。我曾经在测试环境吃过亏,录入出生日期时带了时分秒,导致后续月龄计算在某些边界场景下差一天,最后发现问题就出在时间戳上。date类型只存年月日,从根上杜绝这类问题。
4.2 接种记录表
这张表是整个系统的核心业务表,记录每一次实际接种操作。主要字段包括儿童、疫苗、剂次序号、接种日期、疫苗批号、有效期、接种部位、接种医生、接种单位。其中"儿童+疫苗+剂次"三个字段的组合需要建联合唯一索引,这是防止重复接种的第一道防线。
class Vaccination(models.Model): child = models.ForeignKey(ChildProfile, on_delete=models.PROTECT) vaccine = models.ForeignKey(Vaccine, on_delete=models.PROTECT) dose_no = models.PositiveIntegerField() vaccinated_date = models.DateField() batch_no = models.CharField(max_length=64) expiry_date = models.DateField() injection_site = models.CharField(max_length=32) doctor = models.ForeignKey(User, on_delete=models.PROTECT) class Meta: constraints = [ models.UniqueConstraint( fields=["child", "vaccine", "dose_no"], name="uniq_child_vaccine_dose" ) ]4.3 疫苗批次与库存表
疫苗管理需要两张表:批次表和库存表。批次表记录每个批次的批号、有效期、入库数量、供应商信息。库存表记录当前剩余数量和存放位置。入库操作时就按批次拆分,同一批号同效期的合并成一条库存记录,效期不同的分开。
考虑到接种门诊的实操场景,我额外加了一个开瓶状态字段。某些多剂次疫苗在开封后需要限时使用,系统记录开瓶时间和失效时间,医生扫码时能看到这个批次是否已经开封、距离失效还有多久。这个设计避免了不少浪费,也让护士可以放心使用已经开封但仍在时限内的疫苗。
4.4 不良反应登记表
这张表单独建,是因为异常反应记录不仅是医疗行为记录,还是需要上报的数据源。字段包括儿童、疫苗、接种日期、反应发生日期、反应类型、严重程度、处理方式和转归情况。医生的操作路径是:发现儿童接种后出现异常反应,在工作台直接登记,系统根据登记内容生成标准的异常反应报告文档。
4.5 预约与留观记录表
预约表字段相对简洁:儿童、预约日期、预约时段、状态。建设这套系统时我意识到,基层门诊并不需要复杂的在线预约功能,家长习惯在接种本上手写下次日期,医生更多需要的是内部预约记录,用来做到期提醒和后续统计。所以预约表定位成内部排程辅助表,而非面向公众的预约平台。
留观记录表记录留观开始时间、预计结束时间和实际离院时间。留观流程是接种后观察30分钟,系统会自动计算预计结束时间并在工作台显示倒计时,时间到了提醒护士查看儿童状态。别小看这个功能,它解决了护士因为太忙忘了提醒家长的问题。
5. 从登记到留观:一条完整的状态流转链路
5.1 状态机的定义与流转规则
门诊流程梳理成状态机之后,系统对每个儿童当天在门诊的状态进行实时跟踪。状态定义是这样的:
- 待登记 wait_register:儿童到达门诊,扫描接种本建档完成
- 候诊中 waiting:等待进入问询评估室
- 评估中 assessing:医生正在做知情同意和禁忌筛查
- 可接种 ready:评估通过,等待执行接种
- 已接种 vaccinated:接种完成,进入留观环节
- 留观中 observing:留观计时开始
- 已完成 completed:留观满30分钟,儿童离院
- 当日无法接种 aborted:有禁忌或其他原因
这些状态之间的流转是有约束条件的,比如aborted是终态,不能再流转回ready。每个状态变更都在系统日志里记录操作人和变更时间,一旦出现纠纷,可以完整还原当天每个环节发生了什么、谁操作的、用了几分钟。
5.2 并发场景下的状态保护
接种门诊高峰时段,三个登记台同时工作,容易发生同一个儿童被两个窗口同时操作的情况。第一版我就是简单的"先查状态再更新",结果高峰期出现重复登记的推送记录,家长同时收到两条提醒。
排查之后把方案改成了条件更新:不先查后改,而是直接执行"当状态为待登记时更新为候诊中"这样的原子操作,更新影响行数为0就说明被其他窗口抢先了,提示医生刷新页面重新查看。这个思路同样用在库存扣减上,扣减时加条件"剩余数量大于等于使用数量",从机制上杜绝超扣。
UPDATE appointment_status SET status = 'assessing', updated_at = NOW() WHERE child_id = %s AND visit_date = %s AND status = 'wait_register'6. 上线之后的高频问题和排查反思
系统上线一个月后,我整理了一下反馈和工单,发现问题集中出现在四个方向,每个都可以聊一聊排查过程中的心得。
6.1 日期差一天:时区惹的祸
第一个问题很隐蔽。系统排程显示某疫苗9月1日到期应种,医生觉得9月2日来打也没问题,但系统显示的到期名单里9月2日已经没有这个孩子了。查了一圈发现服务器时区设置成了UTC,而业务场景是北京时间,日期在存储和展示之间转换时产生了偏移。
这个问题的排查过程我先打印了服务器系统日期、数据库日期和页面显示日期三个值做对比,一眼就看出差了8个小时。修复方案是在应用层统一使用本地时区生成业务日期,数据库字段只存date或者带时区的timestamptz,绝不允许数据库会话自行转换时区。
6.2 重复接种:幂等性设计不够
发生了一次重复接种的记录,原因比较典型:两个窗口同时打开同一个儿童的档案,在评估页停留了几分钟,然后都提交了同一种疫苗的接种记录。数据库唯一索引在这里没有拦住,因为两个请求提交前,业务逻辑都先执行了"检查该剂次是否已接种"的判断,两个事务都通过了判断,随后才写入数据。
这次的教训是:业务层的校验永远只是提示,数据库的唯一索引才是防线的底线。修复方案是保留数据库唯一索引,同时捕获唯一约束冲突的异常,在界面上友好提示医生"该剂次已接种,请刷新查看"。现在再遇到并发场景,至少不会产生一条脏数据。
6.3 库存账实不符:拆零与损耗没算清楚
盘点时发现某种疫苗账上显示10支,实际只有8支。逐笔核对发现,医生在接种前检查时发现一瓶疫苗有裂纹,当场废弃了,但系统里没有废弃入口,这笔损耗就从账面上消失了。
这个问题是流程设计漏洞,不是技术问题。后来我加了疫苗损耗登记功能,废弃、破损、过期归为不同的损耗类型,统一走损耗登记流程,库存自动扣减。这个功能虽然不起眼,却是疫苗库存账实是否一致的命门。没有这个入口,再强的技术也保证不了账实相符。
6.4 统计口径之争:接种率到底怎么算
最后一个高频问题来自报表统计。上级要求的接种率统计口径经常调整,有的按儿童数,有的按剂次数,有的按某年出生队列。初期实现的接种率接口只有一个算法,数据对不上两边都对系统有意见。
后来把统计需求做成配置化,每个报表可以配置分子和分母的过滤条件,比如分母是某年出生登记在册儿童数,分子是已接种某剂次的儿童数,按月动态计算。医生平时正常录入数据,统计由系统按配置自动生成。这个模块的复杂度不低,但做完之后系统才真正被管理方接受。
如果让我重新做一次技术选型,我大概率还是会用这套组合。Django的成熟度足以支撑核心业务,Flask的轻量特性让边缘服务开发更顺手,成本并不比单一框架高。我更想提醒后来者的是:别把时间花在研究花哨的架构上,多去接种台旁边站一站,看看医生每天重复率最高的动作是什么。系统每帮医生省下一次查找,医生就多一秒能抬头看看孩子。