一管血的自我修养:LIMS 业务建模

第 2 / 15 章
一管血的自我修养:LIMS 业务建模

想象你是中心 A 检验科的值班检验员。周一早上 8:00,窗口陆续送来 6 个县医院转来的血样——每管上面贴着一个条码号,患者姓名缩写,一个勾了"HIV 筛查"的检验申请单。你的工作是:扫码登记→离心分注→放进迈瑞 CL-6000→等仪器推结果→审核→发报告。

这 5 分钟的动作,背后是 LIMS 对象模型的四层核心实体——Sample(样本)→ SampleItem(分注/原始管)→ Analysis(检验任务)→ Result(实际结果)。你摸不摸得到这个模型的边界,直接决定了:某管样本温度超标后能不能秒级反查到对应哪个 Analysis、某台仪器结果断推后能不能圈定哪些 Result 没发出去。

LIMS模型实体关系

这一章我们不写一行代码,只把这四层实体的"骨架+血肉+连接关系"搭出来——所有字段和关联关系都锚定到 OpenELIS Global 2 的真实源码类,不是我发明的术语。

一、LIMS 对象模型的独特性:跟 MES 有什么不一样?

先把 LIMS 和 MES 的对象模型做一个骨架对照,帮你快速建立坐标系:

维度MES(制造)LIMS(医疗)
顶层业务对象Work Order(工单) —— 某客户的某产品某数量Sample(样本) —— 某患者的某管标本
中层执行对象Process(工序) —— 涂布/辊压/化成Analysis(检验) —— 某样本某检验项
底层产出对象Batch(批次) —— 某工序产出的一批料Result(结果) —— 某检验项的实际值
物理载体设备 + 产线 + 在制品冰箱 + 试管架 + 分析仪
状态机复杂度工单 5~7 态 + 工序 5 态 + OEE 停机编码样本 8 态 + 检验 10 态 + 结果 5 态 + 审核 3 态
合规约束批次追溯 + SPC Cpk + 设备保养ISO 15189 + 电子签名 + 审计追踪 + EQA
互操作对象ERP + SCADAHIS/EMR + 分析仪

MES 的骨架是工单→工序→批次的树,LIMS 的骨架是样本→分注→检验→结果的链——链上每一环都有严格的时间戳和审计要求,因为医疗行业的"操作失误"=诊断延误或误诊,不是"返工一批料"那么简单。

二、第一层:Sample(样本)—— 一管血的户口本

Sample 是整个 LIMS 对象模型的起点。在 OpenELIS 里,它是一个有 50+ 字段 的重量级实体(org.openelisglobal.sample.valueholder.Sample),我把它按"业务分组"拆成四块讲:

2.1 身份与来源:这管血是谁的?从哪来的?

Sample 的身份主键是 accessionNumber(样本编号),这是整个检验科最核心的标识——每一管样本的条码扫进系统时,生成的就是这个号。它跟 fhirUuid(FHIR 资源 UUID)是一对一关系,后者是为了跟 HIS/EMR 做 FHIR 互操作时用的。

来源相关的字段:

  • referringId —— 转诊机构 ID(中心 A 转进来的县医院)
  • clinicalOrderId —— HIS 的医嘱 ID(从 HIS 系统拉过来的临床订单号)
  • domain —— 样本域(CLINICAL/ENVIRONMENTAL/VECTOR,@Domain 注解,跟 Test.domain 呼应,域不同的检验不能混在一个 Panel 里)
  • clientReference —— 客户参考号(转诊机构自己的样本编号)

2.2 时间戳链:这管血经历了什么?

Sample 上有 7 个时间戳,构成了一条完整的"样本时间轴"——这是医疗合规的底线:

字段含义触发时机
enteredDate录入日期检验员扫码登记时
receivedTimestamp接收时间戳样本实际送达检验科时(可能比 enteredDate 晚几天)
collectionDate采集时间护士在患者身上抽血/采集时
requiredBy要求完成时间临床医嘱里的截止时间(比如"24 小时内出结果")
orderEnteredAt / orderPreparedAt / orderReadyAt订单进度时间点2024 年新增的"订单进度状态机"时间锚点
releasedDate发布日期结果审核通过、报告签出时
transmissionDate传输日期结果回传 HIS/公卫系统时

注意 receivedTimestamp 和 collectionDate 可以差好几天——县医院采完血放冰箱,冷链车运到中心 A 可能要 3 天。这两个时间戳的间隔本身就是一个质量指标:如果间隔超过冷链要求(比如 >72h),系统应该自动标记这管样本"可能失效"。

2.3 重采样与修订:样本不是一成不变的

OpenELIS 有两个容易被忽略但很重要的字段组:

重采样链(OGC-580):resampledFromSampleId + resampledToSampleId —— 当某管样本结果异常需要重新采样时,新样本通过这两个字段指向原样本,形成"原样本→重采样 1→重采样 2"的链。这比"删除原样本重建"好得多——因为医疗审计要求"原始数据不能删除"。

报告修订(OGC-776):amendsLhuNumber + amendmentNumber + amendmentReason —— 某管样本的报告发出去之后如果要修改,新报告要记录"这是第几版修订、修订理由是什么",并且通过 amendsLhuNumber 关联原始报告号。这是 ISO 15189 认可的硬性要求。

2.4 其他值得点一下的字段

  • isConfirmation —— 是否"确认检验"(比如某管样本 HIV 初筛阳性,需要再做一次确认检验)
  • priority(OrderPriority 枚举) —— 订单优先级(STAT/URGENT/ROUTINE),决定检验排程顺序
  • gpsLatitude / gpsLongitude —— 采集点 GPS(现场流行病学调查用,比如 COVID 密接追踪)
  • consentGiven / consentFormReference —— 知情同意书(生物样本库/公共卫生研究用)
  • storageSkipped —— 是否跳过存储(某些样本测完就扔,不需要进冷链)

三、第二层:SampleItem(分注)—— 一管血拆成三管

SampleItem(org.openelisglobal.sampleitem.valueholder.SampleItem)是 Sample 和 Analysis 之间的桥梁——一管血进来,可能离心后分注成 3 管:1 管做生化、1 管做免疫、1 管留样进冰箱。

3.1 基础字段

  • sample → Sample(多对一:N 个 SampleItem 属于 1 个 Sample)
  • typeOfSample —— 标本类型(血清/血浆/全血/尿液…字典表)
  • sourceOfSample —— 标本来源(静脉穿刺/指尖采血/气管插管…字典表)
  • quantity —— 原始体积
  • remainingQuantity —— 剩余体积(分注时扣减,乐观锁并发控制)
  • collectionDate / receivedDate —— 采集/接收时间(Sample 级别也有,SampleItem 级别更精确到管)

3.2 分注/别名支持(Feature 001-sample-management)

这是 OpenELIS 相对较新的改造——传统 LIMS 里 SampleItem 只是"原始管",分注后的管子要么新建 SampleItem 要么不建模。OpenELIS 显式建了分注层次:

  • parentSampleItem —— 父管(原始管)
  • childAliquots —— 子管列表(分注出来的)
  • version —— 乐观锁版本号(防止多用户同时分注同一管时 remainingQuantity 变成负数)

递归分注也是支持的——子管可以再分注成孙管。这个模型在实际场景里很有用:某管原始血样 5ml,分注成 2ml 生化管 + 1.5ml 免疫管 + 1.5ml 留样管,每管都有自己的 SampleItem,各自关联到不同的 Analysis。

3.3 到达条件(arrivalCondition)

样本到检验科时可能有各种异常:容器破裂、标签模糊、温度超标、体积不足——SampleItem 上专门有一组字段记录这些:

  • arrivalCondition —— 到达状况
  • arrivalTemperature —— 到达时温度(冷链监控的现场验证)
  • rejected / rejectReasonId —— 是否拒收 + 拒收理由
  • voided / voidReason —— 是否作废 + 作废理由

四、第三层:Analysis(检验)—— 某管血上要做什么检验

Analysis(org.openelisglobal.analysis.valueholder.Analysis)是 LIMS 里最核心的执行实体——它把 SampleItem(哪管血)和 Test(什么检验)绑在一起,并记录这次检验从开始到完成的全部状态。

4.1 基础关联

  • sampleItem → SampleItem(哪管血)
  • test → Test(什么检验——比如"HIV-1 RNA 定量")
  • testSection → TestSection(哪个检验科——生化/免疫/微生物)
  • panel → Panel(如果是检验组合——比如"术前八项",Panel 里包含 8 个 Test)

注意 sampleItem 和 vectorPoolId 互斥(DB CHECK constraint)——vectorPool 是"混样池",公共卫生场景下把多份血清混在一起批量检测,此时 Analysis 不关联单个 SampleItem 而是关联 Pool。

4.2 时间戳与状态

Analysis 的状态机比 Sample 更复杂,因为一个 Analysis 可能经历:排程→开始→完成→审核→报告→被下一个 Analysis 触发反射……

时间戳含义
startedDate开始检验(仪器开始跑)
completedDate完成检验(仪器跑完)
enteredDate结果录入时间(仪器自动推 or 人工录)
releasedDate审核通过、结果有效
printedDate打印到纸质报告
soSendReadyDate / soSendDate发送到 HIS 的时间戳链

4.3 反射检验(Reflexive Testing)—— TestReflexUtil 里的隐藏世界

大纲里原本以为有个独立的 reflexive 包,实际代码里没有——反射检验的触发逻辑藏在 testreflex 包的 TestReflexUtil 里,通过 Analysis 的三个字段实现:

  • triggeredReflex(Boolean)—— 这个 Analysis 是不是被反射触发的
  • parentAnalysis → Analysis —— 谁触发了它(比如 ALT 结果 > 200 触发了 HAV/HBV/HCV 三项)
  • parentResult → Result —— 哪个具体结果值触发了它

触发流程(源码核实):某 Analysis 的 Result 录入后,TestReflexUtil 检查该 Test 有没有配反射规则(类似"ALT 结果 > 200 时追加 HAV 抗体检测"),如果有——新建一个 Analysis(triggeredReflex=true, parentAnalysis=当前 Analysis, parentResult=触发那条 Result),把当前 Analysis 的 triggeredReflex 也置为 true。

这个设计的好处是反射链是显式可追溯的——某 Test 的 Result 一路往上找 parentAnalysis,可以完整还原"谁触发了谁"的因果链,审计员来了一眼就能看懂。

4.4 referredOut(外送)

当中心 A 自己做不了某检验(比如罕见的基因检测),要外送到省级或国家级参考实验室——referredOut=true 标记这个 Analysis 已外送,Analysis 本身作为转诊记录的一部分,关联到 referral 包的完整转诊流程(转诊类型、转诊原因、转诊状态历史、参考实验室结果回传)。

五、第四层:Result(结果)—— 检验的实际产出值

Result(org.openelisglobal.result.valueholder.Result)和 TestResult 是两个不同的东西——命名容易混淆,必须讲清楚:

实体含义生命周期
TestResultTest 的结果目录项(字典值)系统管理员配置时写入,永远不变。比如"HIV-1 抗体检测"的 TestResult 目录是:阳性/阴性/不确定
Result某次检验的实际结果值某 Analysis 完成时生成,1 Analysis 对 N Result(多组分检验:比如"肝功十二项"Analysis 下有 12 条 Result)

Result 的关键字段:

  • analysis → Analysis(多对一)
  • analyte → Analyte(分析物——比如 ALT、AST、胆红素)
  • testResult → TestResult(关联结果目录项:阳性/阴性/数值)
  • value —— 实际值(数字 or 文本)
  • minNormal / maxNormal —— 正常参考范围(值落在区间外时自动标记异常)
  • significance —— 监测分类(POSITIVE/NEGATIVE/INDETERMINATE,按 Test+value 组合配置)
  • qcEvaluation / qcEvaluationDetail —— 质控评估结果(这条 Result 的 QC 状态)
  • validatedDate —— 多组分 Test 中某组分单独验证通过的时间(其他组分还在等)

Result 的特殊字段:多组分 Test

一个 Test 可能包含多个 Analyte(比如"肝功十二项"一个 Test 有 12 个 Analyte),此时一个 Analysis 会产生 12 条 Result,每条 Result 指向不同的 Analyte。validatedDate 字段就是为这种场景设计的——某组分先验证先生效,不用等其他组分。

六、Panel(检验组合)与 BatchWorkplan(排程单)

6.1 Panel

Panel(org.openelisglobal.panel.valueholder.Panel)是 Test 的逻辑分组——"术前八项"Panel 包含 8 个 Test,开单时选 Panel 等于同时开 8 个 Test。关键字段:

  • domain(CLINICAL/ENVIRONMENTAL/VECTOR)—— 域隔离,一个 Panel 不能跨域混 Test
  • loinc —— LOINC 编码(标准检验术语,FHIR 互操作时用)

6.2 BatchWorkplan

BatchWorkplan(org.openelisglobal.batchworkplan.valueholder.BatchWorkplan)是检验的排程/批次单——跟 MES 里的 Worksheet 类似。它的状态机是显式枚举:

DRAFT → ACTIVE → COMPLETED → ARCHIVED
           ↘  (直接归档)

每个状态有合法转换(canTransitionTo)——DRAFT 不能直接跳到 COMPLETED,必须先 ACTIVE。

架构分裂信号(ch02 预告):BatchWorkplan 是 OpenELIS 里唯一一个用 JPA @Entity 注解的核心实体——Sample/Analysis/SampleItem/Panel/Test/Result 全部用的是 hbm.xml + ValueHolder 模式。这意味着 OpenELIS 在 2024 年左右开始往 JPA 迁移,但还没迁完。这个分裂是 ch02 架构解剖的重点素材。

七、字典与术语:LOINC、Dictionary、Scriptlet

LIMS 里"字典表"是一个重量级概念——因为检验术语必须跟 HIS/EMR 的术语对齐,否则结果回传会对不上号。

7.1 LOINC

Test.loinc 和 Panel.loinc 是LOINC 编码(Logical Observation Identifiers Names and Codes)——国际检验医学的标准术语编码体系。FHIR 互操作时,HIS 用 LOINC 识别"这条 Result 是什么检验"。

7.2 Dictionary

Dictionary(org.openelisglobal.dictionary.valueholder.Dictionary)是通用字典表——标本类型、标本来源、拒收理由、到达状况……全部来自 Dictionary。每个 Dictionary 条目有 localization(多语言显示)。

7.3 Scriptlet

Scriptlet(脚本小程序)是 OpenELIS 的一个特色——Test 可以配置一个 Scriptlet,在 Result 录入时自动计算。比如"ALT/AST 比值"——ALT 和 AST 是两个独立 Test,比值由 Scriptlet 在 Result 录入时自动算出,作为第三条 Result 的 value。TestResult 上的 scriptletName 字段就是关联这个。

八、分注 + 重采样 + 反射链:三层递归模型

把前面讲的三层链合起来看,OpenELIS 的 LIMS 对象模型有三层递归能力——这是很多"简易 LIMS"不具备的:

递归层 1:样本分注

Sample ── SampleItem(原始管)
              ├── SampleItem(生化管, parent=原始管)
              └── SampleItem(免疫管, parent=原始管)
                       └── SampleItem(孙管, parent=免疫管)  ← 递归

递归层 2:重采样

Sample (accession=A001)
  ↓ resampledToSampleId=A002
Sample (accession=A002)
  ↓ resampledToSampleId=A003
Sample (accession=A003)  ← 原始结果异常,连续重采样 2 次

递归层 3:反射检验链

Analysis(ALT, triggeredReflex=false) → Result(ALT=215, >200)
  ↓ parentAnalysis
Analysis(HAV-Ab, triggeredReflex=true) → Result(阳性)
  ↓ parentAnalysis
Analysis(HBV-DNA, triggeredReflex=true) → Result(待出)

这三层递归,加上 7 个时间戳的样本时间轴、10+ 状态的 Analysis 状态机,就是 OpenELIS 能扛住"毛里求斯 COVID 4000% 检测量暴涨"的原因——它的对象模型不是为"常规检验"设计的,而是为"公共卫生应急、临床突发、疑难鉴别"这些极端场景设计的。

九、面试怎么答:LIMS 对象模型、跟 HIS/LIS 的边界

问:LIMS 跟 LIS 跟 HIS 有什么区别?

答:HIS 是医院信息系统,管患者的电子病历、医嘱、收费——它知道"谁"、"开了什么单"但不知道"样品在哪、仪器怎么跑的"。LIS 是仪器厂商自带的实验室信息系统,只管"仪器数据进出来"——它知道"仪器上跑了什么、结果多少"但不知道"这管血是谁的、为什么要测"。LIMS 管中间这层——从样本进检验科到报告发出去的全流程,它把 HIS 的订单信息和仪器 LIS 的结果信息串成闭环。边界上:HIS 管"开单→收费",LIMS 管"样本→检验→报告",仪器 LIS 管"仪器数据采集"。

问:OpenELIS 的对象模型有什么值得借鉴的设计?

答:三个点:① 三层递归链——分注/重采样/反射检验都是递归建模,审计链完整可追溯;② 字典驱动 + LOINC 对齐——所有术语走 Dictionary + LOINC,FHIR 互操作零摩擦;③ 反射检验链显式化——triggeredReflex + parentAnalysis/parentResult 让因果链可见可审计,不是藏在规则引擎里的黑盒。反例是很多简易 LIMS 把反射检验写成"如果 ALT>200 则自动新增 HAV 检验",但不记录谁触发了谁,审计时要猜。

问:为什么 Result 和 TestResult 要分开?合并成一个不行吗?

答:TestResult 是"配置时"的字典值(阳性/阴性/数值范围),Result 是"运行时"的实际值——两者的生命周期完全不同。合并成一个表的话,TestResult 作为目录项会被海量 Result 行挤压,查询慢且字典不可变(你不能删除一个 TestResult 因为它被 10 万条 Result 引用)。分开之后 TestResult 作为轻量目录表永远不变,Result 作为重量表可以自由归档/清理——这是典型的"配置态 vs 运行态"分离设计。

十、小结:四层模型的边界

LIMS 的对象模型,本质上是**"生物标本"在"检验流程"里的"状态流转"**:

  • Sample —— 标本的"户口本":谁、从哪来、什么时候采的、什么时候到的
  • SampleItem —— 标本的"分家史":原始管分注成几根管子,每管还剩多少
  • Analysis —— 标本的"检验单":这管血要做什么检验、哪个科室做、做到哪一步了、是不是被反射触发的
  • Result —— 标本的"成绩单":具体值是多少、在不在正常范围内、QC 过了吗

这四层的边界就是 LIMS 的核心边界——任何越界的需求(比如"在 LIMS 里开单收费"那是 HIS 的事,"让仪器开始跑"那是仪器 LIS 的事)都会把系统拉进泥潭。cell-mes ch00 里讲的 MES 边界原则"做什么不做什么",在 LIMS 里一样适用,只是对象模型换了。

下一章(ch02),我们进入架构层——Spring 6 配 Hibernate 5.6、WAR 部署、hbm.xml ValueHolder + JPA @Entity 两代持久层共存、JSP→React 双前端——这个 61 万行代码、167 个顶层包的混搭现场,会比你想的更"原汁原味"。