ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

军团菌肺炎调查表电子化实战:从docx解析到数据建模

军团菌肺炎调查表电子化实战:从docx解析到数据建模 简介一份面向医院传染病报告与公共卫生监测的军团菌肺炎个案调查表模板适用于临床医生、疾控人员及医院感染管理科在法定传染病网报前开展个案信息采集。表单依据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_temp用NUMERIC(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^(?Pcode\d(?:\.\d)*)\s* r(?Plabel[^:]*?) r(?Pdelim[:]) r(?Ptail.*)$, 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-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-form或formily这类库渲染。好处是校验逻辑和表单结构集中在一个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(最高体温应在3443摄氏度之间) 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或院内编码归一化到wbc、pcr_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_dirscan/2023/ --use_angle_clsTrue --langch --save_crop_resTrue识别结果是一个包含文本和坐标的JSON。你可以利用“姓名”这类标签坐标就近找到对应的值。但纸质表存在勾选“⑴”和手写体OCR准确率大约只有80%~90%所以必须采用“先识别、后人工复核”的流程。复核界面只显示OCR识别结果和原图操作员改错不重录效率能提升一半。5.2 按月统计和预警军团菌肺炎的季节性明显信息科可以给疾控和医务科做一个简单的月报。这份调查表的 final_diag1 就是军团菌肺炎确诊直接按发病时间聚合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_no和op_typeALTER 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_at和revision_no就能定位哪里的数据发生了冲突。本文还有配套的精品资源点击获取
返回列表