军团菌肺炎调查表电子化实战:从docx解析到数据建模
2026/9/17 17:19:53 网站建设 项目流程

简介:一份面向医院传染病报告与公共卫生监测的军团菌肺炎个案调查表模板,适用于临床医生、疾控人员及医院感染管理科在法定传染病网报前开展个案信息采集。表单依据2007年省监测方案设计,结构完整,涵盖一般情况、发病与就诊经过、流行病学史、临床表现、实验室检查及转归与最终诊断等模块,并附带编码栏与调查单位、调查者签名等字段,方便纸质填写或电子化录入。资源为单个docx文档,约14KB,内容无需二次排版即可直接参照使用或改编为本院模板。已有71人学习下载,适合发热门诊、呼吸科及公共卫生相关岗位快速获取标准调查表框架,减少制表与条目设计时间。

1. 一张2007年的调查表,为什么现在还能折腾医院信息科

公卫科每个月要报的法定传染病卡里,军团菌肺炎是相对少见但必须单列的病种。这份依据2007年省监测方案制定的个案调查表,字段之细、逻辑之绕,让第一次做电子化的人头疼。编号8位、日期分散、职业14类,还有“有发热则必须填体温”“死亡则必须填死亡日期”这类条件必填。信息科要做的不只是把docx转成网页,而是把这份纸面逻辑变成数据模型。本文抛开临床诊断,纯粹从IT视角拆解:解析docx、设计库表、写校验、对接HIS/LIS,最后给出几个能直接复用的进阶套路。

2. 调查表的数据模型与字段编码:从纸面到结构化

这张表的正文看着像一份联系单,但对系统设计来说,它其实是一个不规范的嵌套JSON。七个一级模块里,既有纯文本描述,也有单选、复选、条件跳转、数值、日期。直接按docx原文建字段会导致重复编号和歧义,比如 2.4.1、2.4.2 实际上是“入院情况”下面的子项,而 3.2.1、3.2.2 只在 3.2 选“是”时才有意义。下面按数据工程习惯重新梳理。

2.1 七个模块的边界和关联

先看表的结构:1 一般情况;2 发病与就诊情况;3 流行病学史调查;4 临床表现;5 临床及实验室检查;6 转归与最终诊断情况。每个模块都挂在同一个调查对象下,因此主键就是第一行的“编号”。编号在纸质表上是8个空格,电子化后建议用8位定长字符串,比如“20070001”,年份+顺序号。

模块之间不是完全平行。比如第4模块“发热”选“有”时,4.1.1“体温(最高)”才必填;第6模块“转归”选“死亡”时,6.2.1“死亡时间”才必填。这类校验不能靠数据库枚举,必须在应用层或触发器里落实。临床和实验室检查里的“血常规入院时”和“胸部X线入院时”又分别包含子字段,建模时需要考虑到底是拆成独立表,还是保留冗余字段。

对于单病种监测数据,我一般建议把“一般情况、临床表现、转归”等低频、非重复字段直接放主表;把“实验室检查”中可能多次复查的项拆成子表。但这份表只有“入院时”一个时间点,所以为了简化上报,把所有一对一字段合并成一张宽表也能接受。关键是字段命名要一致,后续做统计分析时才不用反复join。

2.2 选项编号不能看心情:枚举和编码表

纸质表里用“⑴ 男 ⑵ 女”表示单选,但系统里如果存“男/女”汉字,后续统计和上报就会遇到编码不一致。最佳实践是存代码,显示文本交给前端。参考原表,我整理出下面这些核心枚举字段。

字段用途原表选项建议代码说明
性别⑴男 ⑵女1/2单字节整数
职业⑴幼托或散居儿童 ~ ⒁其他1-14或使用国家行政区划+国标职业分类
是否接触过不明原因发热患者⑴是 ⑵否1/2选1时触发接触地点和关系必填
发热/咳嗽/胸闷等⑴有 ⑵无1/2统一用1/2,避免“有/无”和“是/否”混用
实验室检测结果⑴阴性 ⑵阳性 ⑶未检测1/2/3血清学、PCR、病原分离三个字段共用
转归⑴痊愈 ⑵死亡1/2选2时死亡时间必填

日期字段统一用YYYY-MM-DD,不要存字符串。原表里的“年 月 日”在解析时要用正则补零,例如“2024 1 5”要变成“2024-01-05”。编号字段建议用CHAR(8),而不是INT,因为后续可能包含校验位。

2.3 用SQL建一张宽表:约束、索引和触发器

下面是一份符合上述枚举设计的PostgreSQL建表语句,核心字段全部保留,实际项目可按需裁剪。注意我加了CHECK约束来限制枚举范围,日期字段也用DATE类型。

CREATE TABLE legionella_case ( case_id CHAR(8) PRIMARY KEY, name VARCHAR(64) NOT NULL, gender SMALLINT NOT NULL CHECK (gender IN (1,2)), age SMALLINT, occupation SMALLINT CHECK (occupation BETWEEN 1 AND 14), residence VARCHAR(255), phone VARCHAR(32), workplace VARCHAR(128), onset_date DATE, first_symptom VARCHAR(255), visit_date DATE, confirm_date DATE, admission_date DATE, hospital_name VARCHAR(128), admission_diag VARCHAR(255), discharge_date DATE, discharge_diag VARCHAR(255), exposure_history TEXT, contact_unknown_pneumonia SMALLINT CHECK (contact_unknown_pneumonia IN (1,2)), contact_location VARCHAR(255), contact_relation SMALLINT, fever SMALLINT CHECK (fever IN (1,2)), max_temp NUMERIC(3,1), cough SMALLINT CHECK (cough IN (1,2)), sputum SMALLINT, catarrh SMALLINT, chest_tightness SMALLINT, dyspnea SMALLINT, diarrhea SMALLINT, cns_symptom SMALLINT, comorbidity SMALLINT, wbc NUMERIC(5,2), neut_pct NUMERIC(4,1), lymph_pct NUMERIC(4,1), xray_date DATE, xray_result SMALLINT, serology_result SMALLINT CHECK (serology_result IN (1,2,3)), pcr_result SMALLINT CHECK (pcr_result IN (1,2,3)), culture_result SMALLINT CHECK (culture_result IN (1,2,3)), final_diag SMALLINT, outcome SMALLINT CHECK (outcome IN (1,2)), death_date DATE, survey_unit VARCHAR(128), surveyor VARCHAR(32), survey_date DATE, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now() );

字段名采用snake_case,不要用中文列名,否则后续写统计SQL、对接ODBC都会遇到编码问题。max_tempNUMERIC(3,1),最高体温一般不会超过43摄氏度,小数一位足够。年龄建议直接用整数,不要计算出生日期,因为调查表没有出生日期。

上面的表对“接触过不明原因发热患者”的跳转逻辑没有强制约束,因为我更倾向在应用层做校验,这样能给填报人返回友好的中文错误提示。数据库约束只兜底最基础的枚举合法性,避免非法数据写入影响后续统计。如果一定要在数据库层拦,可以写一个触发器:

CREATE OR REPLACE FUNCTION trg_case_validate() RETURNS trigger AS $$ BEGIN IF NEW.contact_unknown_pneumonia = 1 AND (NEW.contact_location IS NULL OR NEW.contact_relation IS NULL) THEN RAISE EXCEPTION '3.2 选“是”时,接触地点和接触关系必填'; END IF; IF NEW.fever = 1 AND NEW.max_temp IS NULL THEN RAISE EXCEPTION '4.1 发热选“有”时,必须填写最高体温'; END IF; IF NEW.outcome = 2 AND NEW.death_date IS NULL THEN RAISE EXCEPTION '6.2 转归为死亡时,必须填写死亡时间'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_case_validate_bi BEFORE INSERT OR UPDATE ON legionella_case FOR EACH ROW EXECUTE FUNCTION trg_case_validate();

这个触发器把原表里的“条件必填”变成了数据库级约束。好处是无论从哪个入口写入,都逃不过这层校验;缺点是错误信息不够友好,所以前端依然要做预先校验。实际上,我在公共卫生项目里通常把触发器作为最后一道防垃圾数据的手段,应用层负责用户体验。

3. 从Word到录入系统:解析docx并生成可复用的表单

拿到这份docx,很多人的第一反应是打开Word另存为HTML,再手动改成表单。这在小项目里可行,但遇到调查表改版或要部署到多个院区时,手工方式维护成本太高。更规范的做法是直接从docx中解析出“编号-标签-可选值”的映射,然后自动生成前端表单配置。

3.1 用正则抽取字段定义

这份docx不是用表格排版,而是用“编号+文本”的连续段落。以“2.1 发病情况”为例,后面跟着“2.1.1 发病时间: 年 月 日”。用python-docx读段落,再按正则匹配数字.数字开头的内容,就能拿到大部分字段。

import re import docx doc = docx.Document('军团菌肺炎个案调查表(医院法定传染病个案调查表).docx') full_text = '\n'.join(p.text.strip() for p in doc.paragraphs if p.text.strip()) # 匹配 1.1 姓名:... 、 3.2.1 如 3.2 选“是”,则接触地点:... 这类行 pattern = re.compile( r'^(?P<code>\d+(?:\.\d+)*)\s*' r'(?P<label>[^::]*?)' r'(?P<delim>[::])' r'(?P<tail>.*)$', re.MULTILINE ) for m in pattern.finditer(full_text): code = m.group('code') label = m.group('label').strip() tail = m.group('tail').strip() # 过滤掉目录、页眉等干扰行 if len(label) < 30 and code.count('.') <= 2: print(f"{code}\t{label}\t{tail}")

这段代码会输出类似2.1.1 发病时间 年 月 日的记录。注意原表里“2.4.1”和“2.4.2”出现在“2.5 入院情况”之后,编号顺序是乱的,正则只负责抽取,层级关系需要人工修正。另外,像“1.6.1 联系电话 1.6 工作(学习)单位:”这样一行包含两个字段,需要额外按连续多个空格切分。

3.2 把枚举选项映射成表单控件

解析出的tail里往往包含选项,例如“⑴男 ⑵女”。要把这些选项变成radio/select,可以继续用正则抓取括号序号或汉字序号。

def parse_options(text): # 匹配 (1) 或者 ⑴ 以及 1. 这类序号开头 opts = re.findall(r'[((]?[0-90-9一二三四五六七八九十]+[))、..]?\s*[^((]*(?=[((]?\d|[⑩①]|$)', text) clean = [] for o in opts: o = o.strip(' 、,。') if o: clean.append(o) return clean

实际项目中,我更习惯用一套Excel定义文件来维护字段元数据:列名、类型、必填项、跳转条件。docx解析出的结果只作为初稿,人工校对后再导入到配置中心。这样即使原始docx格式不统一,也能保证生成的表单稳定。表单控件映射规则如下:带“⑴⑵”的映射为radio,带“1.”“2.”的映射为select,带“年 月 日”的映射为date picker,其他带数值单位的映射为number input。

3.3 生成JSON Schema驱动前端渲染

后端准备好字段元数据后,可以直接生成JSON Schema,前端用react-jsonschema-formformily这类库渲染。好处是校验逻辑和表单结构集中在一个JSON里,改版时只改配置,不用动页面代码。

{ "title": "军团菌肺炎个案调查", "type": "object", "required": ["case_id", "gender", "onset_date", "fever", "outcome"], "properties": { "case_id": { "type": "string", "minLength": 8, "maxLength": 8, "title": "编号" }, "gender": { "type": "integer", "enum": [1, 2], "enumNames": ["男", "女"], "title": "性别" }, "onset_date": { "type": "string", "format": "date", "title": "发病时间" }, "fever": { "type": "integer", "enum": [1, 2], "enumNames": ["有", "无"], "title": "发热" }, "max_temp": { "type": "number", "minimum": 34, "maximum": 43, "title": "最高体温" }, "contact_unknown_pneumonia": { "type": "integer", "enum": [1, 2], "enumNames": ["是", "否"], "title": "接触史" }, "contact_location": { "type": "string", "title": "接触地点" }, "outcome": { "type": "integer", "enum": [1, 2], "enumNames": ["痊愈", "死亡"], "title": "转归" }, "death_date": { "type": "string", "format": "date", "title": "死亡时间" } }, "dependencies": { "contact_unknown_pneumonia": { "oneOf": [ { "properties": { "contact_unknown_pneumonia": { "enum": [2] } } }, { "properties": { "contact_unknown_pneumonia": { "enum": [1] }, "contact_location": { "title": "接触地点" }, "contact_relation": { "enum": [1,2,3,4,5], "title": "接触关系" } }, "required": ["contact_location", "contact_relation"] } ] }, "fever": { "oneOf": [ { "properties": { "fever": { "enum": [2] } } }, { "properties": { "fever": { "enum": [1] }, "max_temp": { "title": "最高体温(℃)" } }, "required": ["max_temp"] } ] }, "outcome": { "oneOf": [ { "properties": { "outcome": { "enum": [1] } } }, { "properties": { "outcome": { "enum": [2] }, "death_date": { "title": "死亡时间" } }, "required": ["death_date"] } ] } } }

JSON Schema的dependencies能表达条件显示和条件必填,前端会按规则自动控制表单项的显隐。对于这份调查表来说,contact_unknown_pneumonia选“是”时出现接触地点和接触关系,选“否”时隐藏,这正好对应原表 3.2 的跳转逻辑。后端拿到这个Schema后,还能用Python的jsonschema库做服务端校验。

4. 数据采集与质量控制的实战要点

电子表单上线后,真正的麻烦不在建表,而在数据质量。医院填写人往往是从HIS里复制数据,手一抖就会把“2024-03-02”写成“2024-3-2”,或者漏填体温。下面三个环节是必须做的。

4.1 必填项、逻辑跳转和日期合法性校验

先写一个独立的校验函数,既服务后端接口,也可以直接在DataFrame上跑批量历史数据。我习惯把所有规则集中在一个函数里,便于测试。

from datetime import datetime def validate_legionella_case(case: dict) -> list: errors = [] if not case.get('case_id') or len(str(case['case_id'])) != 8: errors.append('编号必须是8位字符') if case.get('gender') not in (1, 2): errors.append('性别代码只能为1或2') for field in ('onset_date', 'visit_date', 'confirm_date', 'admission_date', 'discharge_date', 'xray_date', 'death_date'): val = case.get(field) if val: try: datetime.strptime(str(val), '%Y-%m-%d') except ValueError: errors.append(f'{field} 日期格式应为YYYY-MM-DD,当前值:{val}') if case.get('contact_unknown_pneumonia') == 1: if not case.get('contact_location'): errors.append('3.2 选“是”时,必须填写接触地点') if not case.get('contact_relation'): errors.append('3.2 选“是”时,必须填写与患者关系') if case.get('fever') == 1 and not case.get('max_temp'): errors.append('4.1 发热选“有”时,必须填写最高体温') if case.get('max_temp') is not None and not (34 <= float(case['max_temp']) <= 43): errors.append('最高体温应在34~43摄氏度之间') if case.get('outcome') == 2 and not case.get('death_date'): errors.append('6.2 转归为死亡时,必须填写死亡时间') return errors

这段函数覆盖了原表里最主要的条件必填。注意日期校验用的是strptime,不要用eval或者简单split,因为2024-2-30会被日期库自动修正,而strptime会报错。体温范围设定34-43,是考虑院内可能出现低温症,如果按教科书36-43会误杀。

4.2 从HIS/LIS自动拉取检验结果

调查表第5模块里的血常规、X线、军团菌检测结果,人工录入效率低且容易抄错。如果HIS有返回结构化结果的视图,直接按患者ID和时间范围查:

SELECT visit_id, wbc, neut_pct, lymph_pct, xray_result, pcr_result, culture_result FROM lab_result_summary WHERE patient_id = :patient_id AND order_date BETWEEN :onset_date - INTERVAL '3 days' AND :admission_date + INTERVAL '1 day';

这里把时间范围多扩了几天,因为患者在急诊查血常规的时间可能早于入院日期,而军团菌血清学结果可能有延迟。lab_result_summary是HIS侧事先做好的物化视图,把各科室的检验项按SNOMED或院内编码归一化到wbcpcr_result等字段。数据库里存的是代码,展示时再用维表翻译成“阳性/阴性/未检测”。

4.3 上报前的匿名化处理

法定传染病个案上报到疾控中心时,需要符合《传染病防治法》和网络安全法对个人隐私的保护要求。姓名、联系电话、精确住址不能原样进上报文件。常见的做法是姓名脱敏、电话保留前3后4,住址只保留到区县一级。

import re def anonymize_case(case: dict) -> dict: safe = dict(case) if safe.get('name'): safe['name'] = safe['name'][0] + '**' if safe.get('phone'): phone = str(safe['phone']) safe['phone'] = re.sub(r'(\d{3})\d{4}(\d{4})', r'\1****\2', phone) if safe.get('residence'): # 只保留省市区县,去除街道和村 m = re.match(r'(.+?(?:省|市|区|县))', safe['residence']) if m: safe['residence'] = m.group(1) return safe

注意这里不是加密,加密是双向的,而上报场景需要的是单向脱敏。如果你要做数据回访,建议在院内库保留原文,上报前生成一份脱敏副本,并在副本中保留 case_id 用于纵向关联。预处理后的数据可以直接生成CSV或FHIR的Composition资源,具体格式看你对接的区域平台要求。

5. 把这张表用起来的三个进阶技巧

5.1 用OCR识别历史纸质调查表

如果院内有存量纸质调查表要补录,别再让人工敲。先扫描成300dpi灰度图,再用PaddleOCR做文本检测和识别,最后用正则抽取字段值。

python -m paddleocr --image_dir=scan/2023/ --use_angle_cls=True --lang=ch --save_crop_res=True

识别结果是一个包含文本和坐标的JSON。你可以利用“姓名:”这类标签坐标,就近找到对应的值。但纸质表存在勾选“⑴”和手写体,OCR准确率大约只有80%~90%,所以必须采用“先识别、后人工复核”的流程。复核界面只显示OCR识别结果和原图,操作员改错不重录,效率能提升一半。

5.2 按月统计和预警

军团菌肺炎的季节性明显,信息科可以给疾控和医务科做一个简单的月报。这份调查表的 final_diag=1 就是军团菌肺炎确诊,直接按发病时间聚合:

SELECT to_char(onset_date, 'YYYY-MM') AS month, COUNT(*) AS case_cnt FROM legionella_case WHERE final_diag = 1 AND onset_date >= CURRENT_DATE - INTERVAL '2 years' GROUP BY 1 ORDER BY 1;

如果拿这个结果做预警,可以用“历史同期均值±2倍标准差”生成阈值,超过则提示公卫科关注。注意统一用发病时间而不是确诊时间,否则会导致统计延迟。

5.3 多院区数据合并且保留修订痕迹

集团医院里每个院区可能都有自己的录入系统,合并上报时最怕同一case_id被不同院区修改。建议在合并表增加revision_noop_type

ALTER TABLE legionella_case ADD COLUMN revision_no INT DEFAULT 1; ALTER TABLE legionella_case ADD COLUMN op_type VARCHAR(16); -- INSERT/UPDATE/DELETE

每次修改只插新行,保留旧行,读取时取revision_no最大的记录。这套做法在数据量不大(单病种每年几千条)时性能完全够用,也比引入复杂的CDC工具更容易让维护人员理解。合并时再比较updated_atrevision_no,就能定位哪里的数据发生了冲突。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询