医院检验科的信息化系统(LIS)是个很容易让开发人员大意的地方,表面看是“WPF界面上放几个表格,SQL Server里存一堆检验结果”,但真实项目跑起来之后,你才会发现那些看起来人畜无害的功能,几乎每一个背后都藏着点编程玄机。标本要流转、结果要审核、报告要打印、数据还要跟HIS来回同步,一步没处理好,轻则界面卡顿,重则数据被覆盖、报告打印出来对不上。
这篇文章我想以一个参与过几期LIS改造的开发者视角,把几个典型场景掰开揉碎讲一讲:为什么“拉一个结果列表”这种功能都要仔细设计,为什么审核环节总是出现“我明明改了,怎么又被覆盖”,为什么动态检验项目表格比你想象的更难写。前端就用WPF,后端就用SQL Server,写代码过程中我也顺手把踩过的坑和排查思路一起列出来,希望能给正在做医疗信息化或者类似管理系统的人一点参考。
1. 先搞清楚:WPF + SQL Server 的桌面端组合为什么在 LIS 里依然能打
检验科的系统跟普通进销存不一样,它的核心是“一条标本从申请到报告的状态流转”。这个流转过程包含采集、接收、上机、审核、发布,中间还可能穿插复查、稀释、手工备注。每一步操作都发生在客户端界面上,而且数据要立刻反映到下一个环节。用WPF做这种桌面端,最大的优势是复杂界面可控:多标签页审核、行内编辑、单元格配色、打印预览都能做得比较顺手,数据绑定机制也比老一代WinForm省心不少。SQL Server则在数据完整性上有天然优势,事务、外键、唯一索引、行版本控制都是现成能力,恰好对应医疗数据对准确性的苛刻要求。
1.1 为什么现在还有团队坚持桌面端而不是纯网页
我接触过的医院环境里,LIS客户端通常装在工作站上,仪器数据采集往往要走串口或者网络协议,打印报告又涉及专用纸张和模板。这种场景下,桌面端反而比浏览器更稳,部署一套客户端比折腾浏览器兼容性要简单得多。再加上医院对系统稳定性极其敏感,很多科室并不追求界面有多炫,而是要求“别丢数据、别卡死、别覆盖”,WPF这套成熟得不能再成熟的技术反而成了务实选择。
但要提醒一句,桌面端不等于可以随便写。过去很多LIS的老旧代码就是WinForm时代拼出来的,查询结果全量加载、控件直接绑DataTable、SQL语句在客户端拼字符串,这些做法在数据量小的时候没毛病,标本量一上来就立刻暴露问题。后面说的几个场景,基本都是从这种“能跑就行”的代码里挖出来的典型教训。
1.2 那些看着简单、实际全是细节的功能长什么样
举几个最常见例子:用户说“把某天的结果列表打开看看”,开发人员第一反应是写个SELECT * FROM 检验结果 WHERE 检验日期=@date,然后往DataGrid里一绑就完事。但真上线后会发现,这个查询后面还跟着一堆隐藏需求:只显示当前用户权限范围内的科室、按审核状态过滤、异常结果要高亮、结果超过参考范围要标箭头,甚至有的结果因为稀释倍数要做换算。界面上看到的是一次查询,背后却是一连串业务规则的叠加。
还有“审核结果”这个功能。业务口径很简单:技师确认无误后点一下审核,状态从0变成1。可要是两个审核窗口同时打开同一份标本,一个在录结果,一个在改备注,后提交的人很容易把前一个操作覆盖掉。这种问题不发生在界面,而发生在数据库层,你要做的是并发控制,而不是改前端按钮。类似这种“看起来简单、实则暗藏玄机”的功能,我在项目里总结了几类,下面逐个场景拆。
2. 场景一:结果列表别一次拉全量,WPF 虚拟化和 SQL 分页要一起用
检验科每天产生的数据量不比挂号收费系统少,尤其是生化、免疫这类流水线,一台仪器一天就能出几千条结果,数据库里累积几个月不做归档,单表轻松破几十万行。这时候只要有一个“全部结果”的列表页面,用户习惯又偏偏喜欢按时间范围去查,一查就是一个季度,数据量直接奔着几十万行去。
2.1 全量加载为什么会卡
很多老项目习惯用DataTable做数据中转,查询语句不加分页,一次性把结果全部塞进内存,再设置DataGrid.ItemsSource = dt.DefaultView。数据量小的时候确实感觉快,但几十万行吃到内存里,光是序列化、绑定、计算行高就够WPF喝一壶。最典型的症状是窗口打开要等十几秒,滑动滚动条像在拖一块湿抹布,甚至直接白屏崩溃。
我当时排查过一台情况特别严重的检验仪器工作站,点开历史结果查询,内存占用从400MB飙升到2GB以上。原因就是查询把所有结果都拉回来了,WPF虽然号称支持UI虚拟化,但DataGrid在拿到完整数据源之后,仍然要先构建整个集合的视图信息。如果数据源本身就有几十万行,该有的内存占用一分都不会少。
2.2 WPF DataGrid 的虚拟化设置和容易被忽略的失效条件
先明确一点:虚拟化不等于“只查一部分数据”,它只是让UI只渲染可视区域的行。比如屏幕上能显示30行,那它可能只创建30个行容器,滚动时再复用。这个机制要生效,有几个前提条件必须满足:
一个是要显式开启相关开关,EnableRowVirtualization="True"、EnableColumnVirtualization="True",同时把VirtualizingPanel.IsVirtualizing="True"和VirtualizingPanel.VirtualizationMode="Recycling"都设置好。另一个容易被忽略的是行高。WPF在行高不固定的情况下很难做虚拟化,尤其是默认的Auto行高,会强制DataGrid预先测量每一行内容的高度,这一测就把所有数据遍历了一遍,虚拟化基本失效。我一般会固定一个合适的RowHeight,比如24或者28,既保证视觉统一,也保住虚拟化性能。
还要注意一点:如果数据源不需要完全加载,更好的做法是配合分页查询。WPF只负责当前页的数据,翻页的时候再去数据库取下一页,这样内存里永远只有几十行,滚动和切换才真正流畅。
2.3 SQL Server 分页查询怎么选:OFFSET FETCH 还是 ROW_NUMBER
分页查询写起来有好几种姿势,我建议优先用OFFSET FETCH,它是SQL Server 2012以后的标准写法,逻辑清晰,执行计划也比老式的TOP嵌套稳定。举个例子,我要查某个时间范围内第2页、每页50条记录,代码大概是这样的:
DECLARE @PageNumber INT = 2; DECLARE @PageSize INT = 50; SELECT 结果ID, 标本ID, 项目名称, 结果值, 参考范围, 异常标志, 审核状态 FROM dbo.检验结果 WHERE 检验日期 >= '2025-01-01' AND 检验日期 < '2025-02-01' ORDER BY 标本ID, 结果ID OFFSET (@PageNumber - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY;这里的关键点是ORDER BY必须有,它是分页逻辑的锚点,不然每次取回来的数据顺序都可能不一样。排序字段最好是唯一或足够接近唯一,比如结果ID,避免相同排序值导致重复或漏行。
如果必须兼容SQL Server 2008老库,就用ROW_NUMBER()配合CTE:
;WITH PageData AS ( SELECT 结果ID, 标本ID, 项目名称, 结果值, 参考范围, 异常标志, 审核状态, ROW_NUMBER() OVER (ORDER BY 标本ID, 结果ID) AS RowNum FROM dbo.检验结果 WHERE 检验日期 >= '2025-01-01' AND 检验日期 < '2025-02-01' ) SELECT * FROM PageData WHERE RowNum BETWEEN 51 AND 100;两种写法都行,但要注意索引设计。分页查询的WHERE条件字段和ORDER BY字段最好都有索引,否则数据量一大,排序就是个灾难。我见过一个查询,明明数据量只有五万行,因为排序字段没索引,每次分页都要走SORT操作,耗时一两秒,加了索引之后直接降到几十毫秒。
3. 场景二:审核员的更新覆盖问题,用 rowversion 做行级并发保护
LIS里的审核,本质上是一次更新操作:把某条检验结果的状态从待审核改成已审核,顺便记录审核人、审核时间。业务上好像很简单,但它有个天然的并发隐患。检验科的工作站不止一台,仪器数据还在自动传送,技师A打开一条待审结果正在看,技师B在同一时间把这条结果的结果值修正了并保存。这时候A再点审核,用的却是自己打开时的旧数据,容易把B的修改覆盖掉。
3.1 只靠界面提示解决不了并发问题
早期项目里,大家应对这类问题的方式是在界面上加提示:“确认审核该结果?”用户点确认就执行更新。这个提示一点用都没有,因为问题根本不在于用户操作,而在于两个并发事务。数据库层如果不做控制,后提交的更新就会覆盖先提交的更新。你把审核状态从0改成1,同时把结果值一并写回,如果对方刚刚修过结果值,他就被你的“旧值”踩掉了。
很多系统用“最后保存时间”来判断冲突,比如WHERE 最后修改时间 = @旧时间。这种方法有一定效果,但不够稳,因为业务系统里日期时间精度受多因素影响,同一毫秒内并发更新不是不可能。我更推荐SQL Server自带的行版本控制字段rowversion。
3.2 给结果表加 rowversion,让每一次修改都有“指纹”
rowversion是SQL Server的自动递增版本号,同一行每被更新一次,它的值就会变化一次。它不表示时间,却比时间戳更可靠,因为它由数据库引擎统一维护,不会有业务侧赋值和并发差异。建表的时候加一列就行:
CREATE TABLE dbo.检验结果 ( 结果ID int PRIMARY KEY, 标本ID int NOT NULL, 项目ID int NOT NULL, 结果值 varchar(50) NULL, 审核状态 tinyint NOT NULL DEFAULT 0, 审核人 varchar(32) NULL, 审核时间 datetime NULL, RowVer rowversion NOT NULL );更新语句里把旧版本号作为WHERE条件:
UPDATE dbo.检验结果 SET 审核状态 = @新状态, 审核人 = @审核人, 审核时间 = GETDATE() WHERE 结果ID = @结果ID AND RowVer = @打开界面的旧版本号; IF @@ROWCOUNT = 0 THROW 50001, N'该结果已被其他操作修改,请刷新后再试。', 1;如果更新影响行数为0,说明这行数据在你打开的这段时间里已经变过,系统就不能允许这次更新。这个模式叫乐观并发控制,适合LIS这种“并发冲突概率不高,但一旦冲突后果严重”的场景。悲观锁用极端方式锁定行,在长时间占用界面时反而容易锁死整条数据流。
3.3 WPF 端如何处理“乐观并发冲突”
WPF端的处理没那么复杂。执行更新的方法里捕获SQL Server抛出的错误号,如果碰到50001,就弹一个明确的提示:请刷新数据并重新确认。代码大致这样:
try { // 执行带 RowVer 条件的 UPDATE } catch (SqlException ex) when (ex.Number == 50001) { MessageBox.Show("记录已被其他人修改,请刷新后重新审核。", "并发冲突", MessageBoxButton.OK, MessageBoxImage.Warning); // 刷新当前列表 }这里要注意的是,收到冲突后不要直接把界面数据清空,最好保留用户当前输入,只刷新数据库里这条记录的最新值和版本号,再让用户对比一下。我在项目里加过一个功能:冲突时弹出对比窗口,左右分别显示用户打开的旧值和数据库里的新值,让技师决定是放弃还是强制覆盖。这个设计被科室表扬过,因为他们有时候就是知道新值不对,需要人工纠正。
另外,不要以为加了rowversion就万事大吉。如果业务允许批量审核,比如一次审100条,其中一条冲突,最好把整批回滚还是放行剩余99条说清楚。我的建议是:整批回滚,让用户知道哪一条冲突,避免只审了一部分,业务状态对不上。
4. 场景三:动态项目表格,用 WPF DataGrid 自动生成列配合 SQL 端数据准备
LIS里很少有固定不变的界面。同一个检验申请单上,可能是血常规、尿常规、生化全套、肿瘤标志物,每个检验项目下面又有子项目。比较麻烦的是,检验项目不是写死在界面上的,会随仪器型号、试剂批次、科室自定义规则动态变化。今天血常规还只有18项,明天新换一台仪器,可能就变成24项。如果把字段写死在界面布局里,项目一变就要改代码重新发布,这在医院里完全不可接受。
4.1 检验套餐为什么是动态的
从数据模型来看,检验项目一般是主表加明细表的结构。主表记录标本ID、申请科室、采样时间,明细表记录每一种检验项目的名称、结果值、单位、参考范围。用户在界面看到的“项目表格”,其实就是明细表里某个标本ID下的若干条记录。问题在于,明细表行数是动态的,项目种类也是动态的,WPF不可能预先把每个项目名做成固定的TextBox,只能让表格根据数据自动生成列。
这就是DataGrid的AutoGenerateColumns派上用场的时候。它的逻辑是:只要数据源里有列,它就自动创建表格列,列名默认绑定到DataTable的列名。数据源结构一变,界面立刻跟着变,代码不用改。
4.2 用 DataTable 当临时数据结构,把项目清单动态铺开
我常用的一种做法是:先从SQL Server查出标本基本信息,再根据该标本关联的检验项目,把明细结果拼成一个动态DataTable。这个表有多少列,取决于这个标本实际做了哪些项目。代码写起来也比较直接:
var dt = new DataTable(); dt.Columns.Add("检验项目", typeof(string)); dt.Columns.Add("结果", typeof(string)); dt.Columns.Add("参考范围", typeof(string)); dt.Columns.Add("异常标志", typeof(string)); foreach (var item in resultList) { dt.Rows.Add(item.ItemName, item.ResultValue, item.RefRange, item.AbnormalFlag); } resultGrid.ItemsSource = dt.DefaultView;XAML端只需要声明一个DataGrid,让列自动生成:
<DataGrid x:Name="resultGrid" AutoGenerateColumns="True" CanUserAddRows="False" IsReadOnly="True" SelectionMode="Single" />这里有几个坑要注意。第一,DataTable的列名会和界面表头一一对应,所以列名必须是用户能看懂的显示名,不要用什么col001。第二,DataTable默认列是字符串类型时,表格里的值不会进行数值排序和比较,如果你还想做“异常值变色”,最好在生成列的数据类型上做区分,例如结果值写typeof(string)方便显示,参考范围写成typeof(string),但异常标志用整型或布尔值更方便前端条件判断。
4.3 DataGrid 单元格换行显示:一个容易被忽略的细节
动态表格还有一个常见需求:检验结果备注往往会写很长,比如“该样本轻度溶血,结果仅供临床参考”,如果单元格不换行,整行会撑得特别高,或者直接截断。网上有人搜“wpf datagrid一行变为两行显示”,其实就是在问怎么让备注换行。
要实现换行,不能用普通的DataGridTextColumn加个TextWrapping就完事,得用模板列:
<DataGridTemplateColumn Header="备注" Width="*"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding 备注}" TextWrapping="Wrap" Padding="4,2"/> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn>但这样一设置,行高会自动撑高,如果整张表项目很多,又会出现滚动卡顿。我的经验是:备注列固定宽度,而DataGrid的行高不设为Auto,而是设置一个适中值,同时在TextBlock上开启TextTrimming="CharacterEllipsis"。这样大多数行高度稳定,个别特别长的备注鼠标悬停还能看到完整内容。既照顾了阅读,又不牺牲性能。
5. 场景四:患者档案重复和标本条码冲突,SQL 去重要讲方法
LIS另一类常见问题来自“脏数据”。医院信息系统每天从HIS同步患者信息,接口偶发故障、手工建档、不同科室操作习惯不一样,都可能让同一个患者出现在数据库里两三次。更麻烦的是,如果导入重复患者的同时,还带入了重复的标本条码,就会直接导致检验结果串号,这事在检验科是绝对不能发生的。
5.1 重复数据到底怎么来的
最常见的原因是HIS接口重复发送患者资料,接口没有幂等处理,同一患者建档请求发了两次,LIS侧就落了两条记录。其次是手工建档时没有唯一性校验,只查了姓名,没查身份证号或医保卡号,同名患者就被当成新患者录入。这种情况在老年人多的医院特别常见,因为很多人身份证号比较长,录入员图快只输入姓名和年龄,就产生了重复档案。
一旦患者档案重复,后续开检验申请、绑定标本条码都可能错位。比如患者A的样本,原本该挂他的档,因为姓名重复,被自动匹配到了患者B的档案上,两份报告全部乱掉。
5.2 用窗口函数把疑似重复捞出来
识别重复数据,最简单的做法是对关键字段分组,找出出现次数大于1的记录。但实际操作中,患者档案不能只凭身份证号判断,因为有些历史数据的身份证号为空。这时候要设计一个“优先按身份证号,其次按姓名+生日”的分组逻辑。SQL Server窗口函数在这里特别好用:
SELECT 患者ID, 姓名, 身份证号, 出生日期, 建档时间, ROW_NUMBER() OVER ( PARTITION BY ISNULL(身份证号, CONCAT(姓名, 出生日期)) ORDER BY 建档时间 DESC, 患者ID DESC ) AS rn FROM dbo.患者档案 WHERE 姓名 IS NOT NULL;这段查询把身份证号一样的或姓名加生日一样的患者归为一组,每组按建档时间倒序排,rn=1表示最新记录,rn>1表示疑似重复记录。注意排序时我特意加了患者ID作为次级排序,避免同时间建档导致顺序不稳定。
查出疑似重复只是第一步,千万别直接删。先把结果导出来让科室主任人工确认一遍,因为有的患者虽然身份证号重复,但可能确实是两个不同的人,比如身份证号录入错误,或者家族内共用同一个号码。
5.3 清理确认后加唯一索引,防止再次长草
如果人工确认后确实要合并,一般做法是把重复记录里有效的检验记录转移到保留记录下,然后停用其余的重复档案,而不是物理删除。因为数据库里有很多外键,比如历史检验记录、历史报告单,如果直接删,外键关联可能全断。合并之前还要修改关联表里的患者ID,这个操作用事务包住,一个失败全部回滚:
BEGIN TRANSACTION; UPDATE dbo.检验申请 SET 患者ID = @保留患者ID WHERE 患者ID IN (SELECT 患者ID FROM dbo.疑似重复患者 WHERE 状态 = 1); UPDATE dbo.患者档案 SET 状态 = 0 WHERE 患者ID IN (SELECT 患者ID FROM dbo.疑似重复患者 WHERE 状态 = 1); COMMIT TRANSACTION;最后再加上唯一索引,从源头堵住重复。比如患者档案表,如果业务上允许身份证号全量唯一,可以这样建:
CREATE UNIQUE INDEX UX_患者档案_身份证号 ON dbo.患者档案(身份证号) WHERE 身份证号 IS NOT NULL AND 身份证号 <> '';像这样的“过滤唯一索引”只对非空身份证号生效,不影响历史脏数据的业务兼容。标本条码表则应该建无条件唯一索引,因为条码本身就不允许重复,同一天里两个标本贴了相同条码就是重大事故。
6. 场景五:复杂报表模板,RDLC、HTML 和 SQL 数据的组合拳
LIS的报告模块常常是开发人员最头疼的地方。一张检验报告单,上半部分是患者信息、科室、送检医生,中间是密密麻麻的检验项目和结果,下半部分是参考范围、异常提示、审核签章和备注。不同检验项目的数量不一样,结果有的长有的短,有的还要附带“稀释倍数”“复查标记”等特殊字段。用户的要求往往是“这个综合报告模板务必做得跟纸面完全一致”。
6.1 RDLC 能不能做复杂格式?能做,但要摸清脾气
WPF里集成RDLC报表查看器是可行的,很多人问“wpf rdlc reportviewer是否能做复杂格式的报表”,我得说能做,但体验并不像WinForms那么丝滑。RDLC的强项是报表定义XML化、数据源灵活绑定,支持分组、汇总、子报表。对LIS这种动态行数的报表,用表格区域绑定明细数据就很合适。
不过有几个坑要提前知道:RDLC在WPF中需要引入Microsoft.ReportViewer.WinForms或通过WindowsFormsHost承载,版本兼容性问题比较多;报表里中文字体、每页行数、固定页眉这些都要细心调。真到了多项目、多页签、复杂排版的时候,光调整一份报表就可能耗掉一两天。所以我现在如果在WPF里做报告,会优先考虑另一个思路:用HTML模板渲染。
6.2 替代方案:用 HTML 或 FlowDocument 实现可打印报告
生成HTML报告,然后用WebBrowser控件展示,再调用浏览器打印,这个方案对复杂布局非常友好。CSS控制分页、字体、边距,模板改起来也方便。缺点是对打印精度和分页的控制没有专门的报表引擎那么细。我常用的做法是:在SQL Server把报告数据查出来,按“主表+明细表”结构打包成对象,在前端用模板引擎或字符串拼接生成HTML,最后打印。HTML里可以用CSS@media print设置A4纸尺寸和页边距,比如:
@page { size: A4; margin: 12mm; } body { font-family: "宋体", SimSun; }也有人在WPF里用FlowDocument对象设计报告,它能直接调用PrintDialog进行WPF打印,排版、分页、字体继承都做得不错。但FlowDocument对复杂表格的支持不如网页灵活,如果报告模板要频繁调整,还是HTML更现实一点。
6.3 报表数据经常需要预先“摊平”
报表模块还有一个容易被忽略的点:SQL查出来的结果经常是“明细行”结构,而报表要展示的是“列表式”或“段落式”摘要。比如同一份标本下面有几十条检验项目,报表要显示成“项目名称:结果值 参考范围 标识”的连续文本。这种需求适合用FOR XML PATH做字符串拼接,在SQL层面先把数据整理好:
SELECT 标本ID, STUFF(( SELECT ';' + 项目名称 + ':' + ISNULL(结果值, '未检测') FROM dbo.检验结果明细 AS d WHERE d.标本ID = m.标本ID FOR XML PATH(''), TYPE ).value('.', 'NVARCHAR(MAX)'), 1, 1, '') AS 项目摘要 FROM dbo.检验结果主表 AS m WHERE m.标本ID = @标本ID;这行代码用STUFF去掉最前面的分隔符,用FOR XML PATH把多行结果拼成一行。注意在SQL Server 2016以上版本,也可以用STRING_AGG简化:
SELECT 标本ID, STRING_AGG(项目名称 + ':' + ISNULL(结果值, '未检测'), ';') FROM dbo.检验结果明细 GROUP BY 标本ID;这类预聚合在报表层很实用,查一次,前端直接拿字符串显示,流程简单。但要注意,如果明细特别多,拼接出来的字符串可能很长,报表单元格要做好折行处理,或者控制显示长度,超出部分用“...”代替。
7. 实战排查:LIS 项目里我常用的几条经验
写LIS系统,我最大的体会是:绝大多数线上问题都不是某一端单独造成的,而是两端配合出了问题。有时候是SQL写得低效,有时候是WPF绑定方式不对,有时候纯粹是操作时序没想清楚。下面整理一份我在项目里反复用到的排查表,还有几条自己的坚持。
7.1 常见问题速查表
| 症状 | 常见原因 | 建议排查方向 |
|---|---|---|
| 结果列表打开特别慢 | 查询未分页,DataGrid全量绑定 | 确认开启分页和虚拟化,检查索引 |
| 滚动条拖动卡顿 | 行高自动计算导致虚拟化失效 | 固定RowHeight,关闭不必要的列虚拟化 |
| 审核时数据被覆盖 | 没有做并发控制 | 增加rowversion列,使用乐观并发 |
| 报告打印出来分页不对 | 报表大小或边距没适配 | 调整打印模板的纸张设置 |
| 界面偶发“连接已断开” | 数据库连接池或事务未释放 | 检查SqlConnection的Using释放,以及事务超时 |
| 项目表格列错乱 | 动态DataTable列顺序不稳定 | 生成列时固定顺序,或关闭自动生成列手动绑定 |
| 患者档案重复 | HIS同步接口重复发送 | 增加唯一索引和接口幂等判断 |
排查时先问三件事:问题在哪些工作站出现、什么操作路径触发、数据库执行计划里有没有异常。很多时候,用户说“系统卡”,数据库端一个慢SQL监控就能定位到具体语句,之后再判断是缺索引还是写法的锅。
7.2 我自己的三个坚持
第一,所有查询尽量参数化,永远不要拼字符串。LIS里干扰因素多,项目名、科室名都可能带特殊字符,SQL注入风险不是不存在,而是被很多人低估了。用SqlParameter传参,既能防注入,也能避免引号转义导致怪毛病。
第二,动态界面必须有稳定数据契约。WPF里用DataTable做动态列虽然方便,但不能让它变成一个没人管的中转站。我会给核心的表结构定义清晰的类型,然后在服务层统一把DataTable转换成强类型对象,界面层尽量操作对象而不是裸DataTable,这样后续改起来好维护。
第三,凡是涉及状态流转换的地方,必须在数据库层留一份不可篡改记录。审核、复查、打印、发布,这些操作要不要留痕,不是行政问题,是数据追溯问题。我会在关键表中加操作日志,哪怕是简单的谁、什么时候、把状态从X改成Y,等出了纠纷再补就来不及了。
回头看看,LIS系统真正难的不是某个框架,也不是某条SQL语法,而是怎么把现实操作流程翻译成可靠的数据流转逻辑。WPF负责把复杂界面呈现给技师,SQL Server负责保证数据不乱,两者咬合的地方,才是项目经验真正沉淀下来的地方。