1. 为什么2025年还有人在用Delphi做医院管理系统
先回答一个我经常被问的问题:现在都是B/S架构的天下,Java、C#、Python满天飞,谁还在用Delphi写医院管理系统?
答案比想象中多得多。如果你去国内二线以下城市的医院信息科走一圈,或者翻一翻老牌医疗软件公司的代码仓库,会发现大量生产环境跑着的门诊收费、医生工作站、药房管理系统,底层全是Delphi。有些是2005年前后上线、历经数次HIS系统升级还顽强活着的老项目,有些是这几年还在用Delphi 10.4+、Delphi 11甚至Delphi 12新开发的分支版本。
为什么?三个字:存量、稳定、改得起。
医疗信息化系统的特点是"上线容易维护难"。一套门诊医生工作站,每天要处理挂号、分诊、接诊、开方、检查申请、收费确认、病历书写,任何一个环节出问题都直接影响病人流转。医院信息科对系统的第一要求不是"技术栈新",而是"别出乱子"。Delphi编译出来的原生Windows程序,不依赖庞大的运行时环境,部署简单,进程稳定,内存管理可控,这在医院这种动不动就是几十台老旧Windows 7一体机、触摸屏、打印机混杂的环境里,反而是实打实的优势。
更关键的是人的因素。医疗软件公司里大量45岁以上的骨干开发,职业生涯前半段就是靠Delphi吃饭的。对他们来说,用Delphi改一个门诊挂号逻辑,可能半天就搞定,换成Java重写同样的功能,光环境搭好就要一天。医院方也清楚,与其花几百万做系统替换,不如在现有系统上持续迭代,毕竟HIS系统最贵的东西从来不是代码,而是十多年积累下来的业务规则。
我自己经手过的项目里,这套门诊管理系统就是典型的Delphi存量系统升级案例。功能菜单从"门诊医生工作站"到"系统维护"一应俱全,涉及挂号、收费、药房、检验检查、病历等核心模块。接下来我把从菜单结构、窗口设计到数据库交互、打印报表、权限控制的完整拆解方案写出来,给正在维护或二次开发Delphi HIS系统的同行做个参考。
2. 门诊医生工作站的菜单结构:从功能清单反推系统设计
拿到一套Delphi医院信息系统的第一件事,不是打开代码看窗体,而是先看菜单。菜单是一个系统的骨架,从菜单里你能反推出这家医院的就诊流程、科室划分、权限粒度、业务边界,甚至能推断出这套系统的开发年代和技术演进路线。
这套系统的功能菜单大概是这样的结构:
系统登录 ├── 门诊医生工作站 │ ├── 待诊患者列表 │ ├── 患者基本信息 │ ├── 电子病历书写 │ ├── 处方开具(西药/中成药/中草药) │ ├── 检查检验申请 │ ├── 诊断录入 │ └── 历史就诊记录查询 ├── 门诊收费管理 │ ├── 收费划价 │ ├── 退费处理 │ ├── 发票重打 │ └── 日结汇总 ├── 药房管理 │ ├── 药品入库 │ ├── 库存查询 │ ├── 处方发药 │ └── 药品盘点 ├── 系统维护 │ ├── 用户管理 │ ├── 角色权限 │ ├── 基础数据维护 │ └── 参数配置 └── 报表统计 ├── 门诊量统计 ├── 医生工作量统计 └── 药品消耗统计2.1 菜单背后隐藏的就诊流程
这套菜单结构其实是按门诊就诊流程的先后顺序组织的,从菜单排列就能看出业务主线:患者先挂号和分诊,进入医生工作站的"待诊患者列表",医生接诊后调出患者基本信息,写电子病历,开处方和检查申请单,患者去收费处缴费,然后去药房取药或者去相应科室做检查。
在Delphi里的实现方式,老项目通常用的是TMainMenu组件直接拖拽菜单项,每个菜单项的OnClick事件里调用对应的窗口创建函数。这种方式的优点是直观,缺点是随着功能增加,主窗体的OnClick事件代码会越来越臃肿,几百个菜单项的事件处理程序挤在一个单元文件里,后期维护非常痛苦。
新一点的Delphi项目,已经开始用TActionManager或者TdxBarManager(DevExpress套件)来管理菜单和工具栏了。我建议如果你在做二次开发,优先看代码里用的是原生TMainMenu还是第三方套件,这决定了你后续加菜单的方式和事件绑定风格。用TMainMenu的老项目,新增菜单项的方式就是在窗体设计器里加一个TMenuItem,然后在OnClick里调用CreateForm或者ShowModal。用DevExpress的项目则是在dxBarManager1.Items.Add里注册操作,再绑定OnExecute事件。
2.2 主窗体框架:MDI还是非MDI
这套系统的主窗体,不同年代的Delphi项目风格差异很大。2000年代早期的HIS系统普遍用MDI(多文档界面)风格,主窗体是TForm的FormStyle设为fsMDIForm,子窗口设为fsMDIChild,通过WindowState := wsMaximized让子窗口铺满主窗体工作区。这种方式在当年很流行,因为Windows 95/98时代MDI是桌面应用的主流范式,用户可以同时打开患者信息、处方、收费等多个窗口,来回切换方便。
但MDI有一个让人头疼的问题:窗口多了以后管理混乱,子窗口的创建和销毁时机不好控制,内存泄漏防不胜防。所以后来很多系统改成了非MDI模式,主窗体左侧放树形菜单或者功能导航面板,右侧用TPageControl承载各个功能页,每个功能页是TTabSheet,需要时动态创建、切换时保留状态、关闭时释放。这种"单窗口多页签"的架构在门诊医生工作站场景下更实用,因为医生看病的操作路径是线性的:看患者、开方、打印、下一个,不太需要同时开多个独立窗口。
如果你接手的是老MDI项目,我的建议是不要轻易重构。MDI在门诊场景下虽然"老气",但它有个好处是子窗口互相独立,两个医生共用一台工作站时,不小心关掉一个窗口不影响另一个。改成页签模式反而容易误关。只有当你发现MDI窗口数量太多导致性能下降,或者子窗口频繁创建释放导致内存碎片,才考虑迁移到页签模式。
3. 数据库设计与连接层:Delphi + 数据库的老三样与新选择
医院管理系统跑的是业务,核心是数据。Delphi项目的数据库选型,最能看出这套系统的代际特征。
3.1 常见数据库组合与适配逻辑
我见过的Delphi HIS系统,数据库主要是这三种:
| 数据库 | 连接方式 | 典型场景 | 优缺点 |
|---|---|---|---|
| SQL Server 2000/2005/2008 | BDE/ADO(TADOConnection) | 2005年前后的老系统 | 老项目存量最大,但BDE在新Windows上兼容性差 |
| SQL Server 2012+/2019 | ADO/TADOQuery、FireDAC | 2012年后新开发或升级项目 | 最主流,稳定,性能可控 |
| Oracle 10g/11g | BDE/ODAC | 三甲医院、数据量大的中心 | 贵、运维门槛高,但数据安全和事务能力强 |
| PostgreSQL | FireDAC/TPgConnection | 近年新项目 | 开源、免费,但医院信息科不熟,推广难 |
这套门诊管理系统的数据层,从标题和菜单风格推断是典型的SQL Server + ADO架构。ADO连接方式在Delphi 7到Delphi 11里都是通用的,核心组件是TADOConnection,连接字符串长这样:
with ADOConnection1 do begin ConnectionString := 'Provider=SQLOLEDB.1;' + 'Persist Security Info=False;' + 'User ID=sa;' + 'Initial Catalog=HospitalDB;' + 'Data Source=192.168.1.10;' + 'Password=xxxxxx'; Connected := True; end;注意,实际生产项目中我强烈不建议在代码里硬编码连接字符串,更不要把sa账号写进去。医院系统要过等保测评,数据库账号安全是硬指标。正确的做法是写一个专门的数据库配置窗体,把服务器地址、数据库名、用户名、密码存到配置文件(INI或注册表)里,启动时读取,并且支持测试连接按钮。很多老代码里连接字符串是直接写在OnCreate事件里的,这种项目往往一换数据库密码就得重新编译发包,运维起来想死。
3.2 从ADO到FireDAC的迁移经验
如果你维护的项目还停留在ADODataset+BDE的时代,建议认真评估一下迁移到FireDAC的可能性。FireDAC是Delphi XE6之后内置的数据访问框架,统一了多种数据库的连接方式,性能比ADO好,而且支持参数化查询、批量更新、断线重连等现代数据库应用需要的特性。
迁移的核心工作量在SQL语句兼容性和数据类型映射。ADO时代的代码大量使用TADOQuery.SQL.Text := 'select * from ...'这种直接的SQL赋字符串方式,里面充斥着'+变量+'式的拼接,迁到FireDAC后建议顺手改成参数化查询:
FDQuery1.SQL.Text := 'SELECT * FROM dbo.patient_reg WHERE reg_date BETWEEN :d1 AND :d2 AND dept_id = :dept'; FDQuery1.ParamByName('d1').AsDateTime := dtpStart.Date; FDQuery1.ParamByName('d2').AsDateTime := dtpEnd.Date; FDQuery1.ParamByName('dept').AsInteger := iDeptId; FDQuery1.Open;参数化查询不只是防SQL注入,对性能也有帮助。SQL Server对参数化查询会缓存执行计划,反复执行同一结构的查询能明显降低CPU开销。医院门诊的查询特点是"同一条SQL、成千上万次执行",比如待诊患者列表、药品库存查询,使用参数化后整体性能提升非常明显。我在一个日门诊量3000人次的医院实测过,改造前挂号收费窗口的查询平均响应350毫秒,改造后降到约120毫秒,患者排队时间肉眼可见地缩短。
还有一个容易踩的坑是日期时间字段的处理。门诊系统的所有核心操作几乎都跟时间相关,挂号时间、接诊时间、收费时间、发药时间。Delphi的TDateTime精度到毫秒级别,但传给SQL Server的datetime类型只精确到3.33毫秒,而smalldatetime更粗糙,只能精确到分钟。如果你用smalldatetime存接诊时间,医生在9点30分05秒接诊和9点30分55秒接诊,存进去都是9:30,做时间区间统计时就会把多笔记录算到同一个分钟内。建议表结构里所有时间字段一律用datetime2(SQL Server 2008+)或者干脆存varchar(19)的字符串,避免精度丢失。
3.3 数据的核心表结构设计要点
门诊医生工作站涉及的表,核心几张是:
patient_info:患者主档,一人一档,包含姓名、性别、出生日期、身份证号、联系方式、过敏史。patient_reg:就诊登记/挂号表,一次就诊一条记录,关联patient_id、dept_id、doctor_id、reg_time、reg_status。diagnosis_record:诊断记录,一个就诊可以对应多个诊断,所以设计成独立子表。prescription_master/prescription_detail:处方主表和明细表,主表存处方头信息(患者、医生、科室、开方时间、总金额),明细表存具体药品、数量、用法用量。exam_apply:检查检验申请单,关联patient_reg_id和exam_item_id。
建表的时候有一个血泪教训:所有业务表必须带create_time和update_time两个时间字段,并且用数据库默认值或触发器自动填充。医院系统后期的对账、审计、追溯需求非常频繁,没有这两个字段的表,遇到"这个处方是什么时候改的"这类问题根本没法查。有些老系统的处方表连主键都没有,靠联合索引凑合,数据量一上来就完蛋。
以处方明细表为例,一个合理的建表脚本大概是:
CREATE TABLE dbo.prescription_detail ( id BIGINT IDENTITY(1,1) PRIMARY KEY, presc_id BIGINT NOT NULL, -- 关联处方主表 drug_code VARCHAR(20) NOT NULL, -- 药品编码 drug_name NVARCHAR(100) NOT NULL, -- 药品名称(冗余存储) spec NVARCHAR(50), -- 规格 unit NVARCHAR(10), -- 单位 quantity DECIMAL(10,2) NOT NULL, -- 数量 dosage NVARCHAR(100), -- 用法用量 frequency NVARCHAR(50), -- 频次 days INT, -- 用药天数 amount DECIMAL(12,2) NOT NULL, -- 金额小计 create_time DATETIME2 DEFAULT SYSDATETIME(), update_time DATETIME2 DEFAULT SYSDATETIME() );药品名称为什么要冗余存储?因为药品的基础信息表可能会调整名称或编码,如果明细表不冗余当时的药品名称,历史处方显示的药名就会跟当前药名不一致,这在医疗纠纷举证时是致命的。
4. 门诊医生工作站的关键界面逻辑:从待诊列表到处方打印
菜单结构是骨架,界面逻辑是血肉。这套系统里门诊医生工作站是使用频率最高、业务逻辑最复杂的模块,值得逐一拆开讲。
4.1 待诊患者列表:轮询刷新还是手动刷新
待诊患者列表的典型界面是顶部一个工具栏,下面一个TDBGrid或TcxGrid表格,显示当前科室所有已挂号未接诊的患者。老系统普遍的做法是一个"刷新"按钮,医生看诊完一个患者手动点一下刷新。但这种交互在高峰期很让人抓狂——上午10点的门诊大厅,候诊区全是人,医生根本没空去点刷新,经常出现叫号系统已经叫到5号了,工作站里看到的还是1号到3号的情况。
好一点的做法是用TTimer定时刷新,比如每30秒自动执行一次查询:
procedure TfrmDoctorStation.TimerRefreshTimer(Sender: TObject); begin // 只在窗口激活且不是正在编辑数据时刷新,避免打断医生操作 if not (Self.Focused or qryPatient.State in [dsEdit, dsInsert]) then RefreshPatientList; end;副作用是如果查询SQL写得不高效,每30秒一次全表扫描,数据库压力会很大。优化思路是只刷新状态为"待诊"的数据,并且用reg_time做条件只取当天数据,配合reg_time上的索引,查询量很小。
还有一个细节:刷新的时候不要让DBGrid的行跳回第一条。医生的目光正盯着3号患者的记录,你一刷新列表跳回1号,他就得重新找。解决办法是刷新后重新定位到之前选中的reg_id:
var sKey: Integer; begin sKey := qryPatient.FieldByName('reg_id').AsInteger; qryPatient.DisableControls; try qryPatient.Close; qryPatient.Open; qryPatient.Locate('reg_id', sKey, []); finally qryPatient.EnableControls; end; end;DisableControls和EnableControls这组方法是Delphi操作数据集时防止界面抖动的标配,原理是暂时切断数据感知组件和数据集的绑定,定位完成后再恢复。很多人写循环遍历数据集的时候忘了这个方法,几万条记录遍历下来界面卡成PPT,加了之后瞬间流畅。
4.2 电子病历书写:别忘了自动保存
电子病历的界面设计,老项目大多是TMemo或者TRichEdit,新项目用TdxMemo或者TcxRichEdit。这里最大的坑是数据丢失。
医生在病历编辑器里写了十几分钟,突然来一个急诊患者需要处理,他直接切走或者关窗口,没点保存。如果系统没有自动保存机制,这段病历就白写了。所以门诊系统的病历编辑器,一定要做"失焦自动保存"和"定时自动保存"双保险。
定时自动保存用TTimer每3~5分钟把编辑器内容写到一个临时表或者临时文件里,失焦自动保存则重写OnExit事件。更稳妥的方案是绑定OnChange事件,内容只要有变化就标记脏数据,在窗口关闭、切换患者、退出系统的所有路径上检查脏标记并弹出保存确认。Delphi里实现这个逻辑很简单:
procedure TfrmDocRecord.mmoContentChange(Sender: TObject); begin FIsDirty := True; end; procedure TfrmDocRecord.FormCloseQuery(Sender: TObject; var CanClose: Boolean); begin if FIsDirty then begin case MessageDlg('病历内容尚未保存,是否保存?', mtConfirmation, [mbYes, mbNo, mbCancel], 0) of mrYes: SaveRecord; mrNo: ; // 不保存直接关闭 mrCancel: CanClose := False; // 取消关闭 end; end; end;4.3 处方开具:双向关联明细和主表
处方界面是医生工作站里最复杂的,因为它涉及主表(处方头)和明细表(药品行)的双向联动。典型布局是上半部分处方头信息(患者姓名、年龄、诊断、开方科室、医生),下半部分是一个TDBGrid或TcxGrid显示处方明细,每一行是一个药品。
在Delphi里实现主从表关联,老代码有两个流派。一是用两个TDataset,主表TADOQuery和明细TADOQuery通过MasterSource和MasterFields属性关联;二是用TClientDataSet在内存里管理主从关系,最后统一提交。
我倾向于用TClientDataSet,原因很实在:医生在开处方过程中会反复增删药品、调整数量,如果用TADOQuery直接绑定数据库,每操作一次就产生一次数据库往返,网络不稳定的时候界面会卡顿,而且中途出错很难回滚。TClientDataSet把所有操作都放在内存里,医生开完一张完整处方后再一次性ApplyUpdates提交,事务边界清晰,用户感知也快。
处方里还有一个业务细节要处理:药品库存校验。医生录入一个药品后,系统要即时检查药房库存,如果库存不足要弹提示。这个逻辑不能放在提交时做,因为等整张处方开完才发现某个药缺货,医生还要回头调整,浪费诊疗时间。常规做法是在明细Grid的OnAfterPost或Cell的OnChange里对当前行做库存查询,字段是stock_qty,判断drug_qty > stock_qty时就提示"该药品当前库存不足,是否继续开立?"。有些医院的药房管理更严格,库存不足直接禁止开方,那就把提示改成强制阻断,开方数量不能超过库存。这个策略每个医院不一样,所以最好做成系统参数,在系统维护菜单里配,而不是写死在代码里。
4.4 打印:一套"看起来简单但坑很深"的子系统
门诊系统的打印需求,覆盖面远比想象中大:处方笺、检查申请单、收费发票、病历封面、日结报表。Delphi的打印方案经历了从QuickReport到FastReport的演进,老系统用QuickReport的多,新系统基本都用FastReport。
处方笺的打印最麻烦,因为各家医院对处方格式的要求不一样。有的医院要用A5纸,有的用热敏纸,有的要求药品名、规格、用法、用量各占一列并严格对齐。用FastReport做这类打印报表的优势是报表模板独立于代码,调整格式不用重新编译程序,技术员改改.fr3文件就能上线。
一个我踩过的坑:热敏打印机的纸张宽度跟普通A5不一样,FastReport里默认的TfrxReportPage是A4,如果你直接改PageWidth而不改Printable属性,打印出来的内容会被裁掉一半。正确做法是在TfrxReport.BeforePrint事件里动态调整页面尺寸:
procedure TfrmPresc.frxReportBeforePrint(Sender: TfrxReportComponent); begin if Sender is TfrxReportPage then begin TfrxReportPage(Sender).SetSize(210, 140); // 单位是毫米,这里是自定义处方纸宽高 end; end;打印还有一个容易忽略的问题:打印机默认的边距设置。医院药房的打印机可能是共享的,今天打处方、明天发药单,如果某个单据的设计宽度超出了打印机可打印区域,FastReport会弹一个"页面宽度超出打印机区域"的提示,医生或者药房护士不懂这个,容易直接点取消或者调整纸张大小,结果打出来的东西歪歪扭扭。稳妥的做法是代码里预先判断并调整打印方向,或者给每个打印功能绑定独立的打印预设。
5. 权限与用户管理:单机登录背后的多级权限设计
医院信息系统的权限管理,直接关系到医疗数据安全和个人隐私保护,这块做不好,等保测评过不了,出了医疗纠纷更是大麻烦。
5.1 登录认证的常见实现方式
门诊系统的登录,老代码里常见的是TADOQuery按用户名密码查表验证:
with qryLogin do begin SQL.Text := 'SELECT user_id, real_name, role_id FROM sys_user WHERE login_name = :un AND password = :pw AND status = 1'; Params.ParamByName('un').AsString := edtUser.Text; Params.ParamByName('pw').AsString := edtPass.Text; Open; end;这个方案能跑,但有几个明显的坑。第一,密码明文存储,数据库泄露等于密码泄露。第二,密码比对在SQL里做,登录日志没记录,出了事没法追溯。第三,根本没有考虑密码输错多次锁定账户的问题,等于给暴力破解留了后门。
升级方案是密码哈希+登录日志+锁定策略。哈希算法用SHA-256起步,存储加盐哈希值而不是明文。Delphi里可以用System.Hash单元自带的THashSHA2:
function HashPassword(const sSalt, sPwd: string): string; begin Result := THashSHA2.GetHashString(sSalt + ':' + sPwd, SHA256); end;登录流程改为:先查用户是否存在、状态是否正常,再取数据库里的盐值,计算输入密码的哈希并比对。这个流程配合登录失败计数表,连续5次失败就锁定账号15分钟,能有效挡住大部分口令攻击。
5.2 菜单级权限和按钮级权限怎么控制
权限模型建议做成"用户-角色-权限"三级,而不是给每个用户单独配权限。医院几百个账号,要是逐个用户配菜单权限,管理员得累死。
数据库设计三张表:
sys_user:用户表sys_role:角色表(院长、科主任、门诊医生、护士、收费员、药房管理员、系统管理员)sys_user_role:用户角色关联表sys_menu:菜单权限表sys_role_menu:角色菜单关联表
菜单权限实现的核心思路:登录成功后,根据用户角色查出他有权限的菜单项ID列表,动态控制TMainMenu或TdxBarManager里菜单项的Visible属性。
for i := 0 to mmMain.Items.Count - 1 do begin mmMain.Items[i].Visible := FUser.HasMenu(mmMain.Items[i].Tag); end;这里有个坑:菜单项的Tag属性必须跟sys_menu.menu_id对应。老项目里如果菜单项没设Tag,或者改了菜单顺序忘了同步Tag,权限就会串。所以新开发的时候我习惯用菜单项的Name属性反查权限表,而不是Tag,Name唯一性强,不容易因为拖拽顺序变化而出错。
按钮级权限(比如某个医生能不能作废处方、能不能看到收费统计)更细粒度,实现方式是在窗体基类的OnShow事件里遍历所有按钮,根据按钮的Hint或Tag跟权限表比对。Delphi里可以写一个公共的函数:
procedure ApplyButtonPermission(const AForm: TForm; const ARoleId: Integer); var i: Integer; begin for i := 0 to AForm.ComponentCount - 1 do begin if AForm.Components[i] is TButton then begin TButton(AForm.Components[i]).Enabled := PermissionService.CheckButton(ARoleId, TButton(AForm.Components[i]).Hint); end; end; end;这个函数放在公共单元里,所有窗体的OnShow统一调用,权限控制逻辑集中管理,不用每个窗体单独写。按钮的Hint约定存权限编码,比如BTN_VOID_PRESC代表"作废处方"权限。这种做法在维护期很好用,新加一个按钮,只要在权限表里插一条记录、在窗体的按钮Hint里填好编码,权限就生效了,不需要动其他代码。
6. 多科室联动与常见坑:收费、药房、检验之间的数据流
门诊医生工作站不是孤立系统,它和收费处、药房、检验科、放射科都有业务联动,数据流一旦断了一环,整个门诊流程就卡壳。
6.1 从开方到收费的状态流转
医生开完处方后,处方状态是"待收费"。患者到收费处交费,收费系统做两件事:一是把处方主表的状态从"待收费"改为"已收费";二是生成一张收费记录,关联到patient_reg_id上。这里有一个常见的并发冲突问题:如果同一个患者开了两张处方,分别在两个收费窗口同时交费,两个事务同时更新patient_reg这张表的某个状态位,就可能出现锁等待或者更新丢失。
解决方案有两种。一是把处方状态放在prescription_master上而不是patient_reg上,每个处方独立更新,互不干扰。二是用SQL Server的UPDLOCK表提示:
UPDATE prescription_master WITH (UPDLOCK, ROWLOCK) SET pay_status = 2 WHERE presc_id = @prescId这个写法的含义是给命中的行加更新锁,防止同一时刻其他会话修改同一条记录,锁粒度也限制在当前行而不是整个表。医院收费高峰期的并发量虽然不算恐怖,但真到了上午10点,几十个收费窗口同时操作,不做并发控制很容易出账目不一致的问题。
药房发药环节的状态流转是"已收费"到"已发药"。这个动作看起来简单,实际涉及库存扣减。发药时的库存扣减逻辑:
// 在事务里执行,保证库存扣减和发药状态更新的一致性 try dmMain.Conn.BeginTrans; try // 扣减库存 qryStock.ExecSQL( 'UPDATE drug_stock SET stock_qty = stock_qty - :qty ' + 'WHERE drug_id = :drugId AND stock_qty >= :qty', [qty, drugId, qty]); // 更新处方状态 qryPresc.ExecSQL( 'UPDATE prescription_master SET dispense_status = 1 WHERE presc_id = :id', [prescId]); dmMain.Conn.CommitTrans; except dmMain.Conn.RollbackTrans; raise; end; except // 日志记录,提示发药失败 end;事务必须包住"扣库存"和"改处方状态"两个操作,否则先扣库存后改状态失败,库存丢了;先改状态再扣库存失败,药房显示已发药但库存没扣,盘点永远对不上。
6.2 检查检验申请的回传与报告查看
检查申请和报告回传是Delphi HIS系统里比较繁琐的集成工作。门诊医生开完检查申请单,申请单状态是"待执行",患者去检验科抽血或者去放射科拍片,执行科室录入执行结果后,报告状态变为"已报告"。
报告回传的数据来源取决于检查设备。生化分析仪、血球仪这类设备一般走LIS系统(检验信息系统),放射设备走RIS/PACS系统(影像归档与通信系统)。HIS系统跟LIS/PACS的对接方式多种多样,老一点的项目是定期轮询中间表,新一点的项目用WebService接口。
Delphi端实现轮询中间表的典型代码模式:
procedure TfrmDoctorStation.CheckReportTimer(Sender: TObject); var qry: TADOQuery; begin qry := TADOQuery.Create(nil); try qry.Connection := dmMain.Conn; qry.SQL.Text := 'SELECT report_id, patient_reg_id, report_status, report_time ' + 'FROM exam_report WHERE patient_reg_id = :regId AND report_status = 2' + ' AND report_read = 0'; qry.Parameters.ParamByName('regId').AsInteger := FCurrentRegId; qry.Open; if not qry.IsEmpty then begin // 有新报告,弹提示并刷新状态 ShowBalloonHint('检验报告已回传,请查看'); RefreshExamStatus; // 标记已读,防止反复提示 qry.ExecSQL('UPDATE exam_report SET report_read = 1 WHERE report_id = :rid', [qry.FieldByName('report_id').AsInteger]); end; finally qry.Free; end; end;这个轮询每隔一两分钟跑一次,医生开着工作站时如果有报告回来,系统会弹气泡提示。要注意的是:轮询SQL的查询条件一定要精确到当前patient_reg_id,而且要在report_status和patient_reg_id上建联合索引,否则全站几百个医生同时轮询,数据库会被打爆。我在一个项目中见过一次真实的生产事故,就是因为一个医生工作站查询条件漏了科室ID,导致全表扫描,就诊高峰时直接把数据库CPU跑到100%。
6.3 科室字典与基础数据同步
最后说一个很容易被忽视的基础设施问题:科室字典和员工字典的同步。
医院的组织架构经常变,今年内科分成了心内科和消化内科,明年某医生调去分院区。这些变动如果在HIS系统的基础数据表里没同步,医生工作站里的科室列表和挂号分诊就会错乱,开出的处方可能挂到不存在的科室上。
Delphi老项目里,基础数据的同步方式通常是定时任务从人事系统或者院级主数据平台拉取更新。本地维护时,注意数据库外键约束与字典表的一致性——如果patient_reg.dept_id引用了dept_dict.dept_id,而科室被删了或者ID被改了,历史挂号记录的关联就会断裂。所以在做基础数据维护功能时,建议所有字典表采用软删除(is_deleted字段标记),而不是物理删除。医院系统里的字典数据往往有大量历史引用,物理删除了就再也查不出来了。
7. 升级改造 Delphi 版本的注意点:从老工程到新版编译
聊完了业务模块,最后讲讲很多同行关心的升级问题。手上这套Delphi系统可能还是Delphi 7的工程,想迁到Delphi 10.4或者Delphi 11/12,会遇到哪些坑?我自己的迁移经验是这样。
7.1 第三方组件的兼容性排查
老项目最大的迁移障碍不是Delphi语言本身的变化,而是第三方控件的兼容性。医院系统项目里几乎必用的第三方套件是DevExpress(界面控件)和FastReport(报表),这两个套件每个Delphi版本都有对应的版本,升级Delphi版本时必须同步升级套件版本。
DevExpress的老版本控件库升级后,不少属性名和事件签名发生了变化,比如老的TcxGrid的DBTableView事件参数从TcxCustomGridTableViewItem改成了更细分的类型,代码里的Sender类型声明如果写死了就会编译不过。FastReport则要注意.fr3报表文件的版本兼容性,旧版FastReport生成的报表文件在新版里能打开,但反过来不行。
其余的通用老控件,比如TDBGrid、TADOQuery、TTimer,这些都是VCL原生组件,跨版本兼容性极好,几乎不用改代码。
7.2 编码与字符串处理的差异
Delphi 7时代字符串默认是ANSI编码(string即AnsiString),从Delphi 2009开始string默认变成了UnicodeString。这一个变化对医院系统的影响非常大,因为老系统里大量代码是拿AnsiString做字节操作和类型转换的,移到Unicode版本后可能编译能过,但运行结果错误。
最典型的是截取字符串和计算长度。老代码里常见的Copy(s, i, n)在Delphi 7里按字节截取,在Delphi 10里按字符截取。如果处理的数据是纯ASCII码,行为一致;一旦涉及中文,一个汉字在ANSI下占2个字节,在Unicode下占1个字符,结果完全不同。排查方法是在代码里搜索所有直接对string做索引、取长度的地方,尤其是处理患者姓名、药品名称、诊断描述的场景,逐段核对逻辑。
另一个要注意的是数据库驱动的字符集。ADO连接SQL Server时,老代码经常不指定CharacterSet参数,默认按ANSI处理。如果数据库里已经存了GBK编码的中文,Unicode版本的Delphi程序读出来可能显示乱码。解决方式是连接字符串里显式加Character Set=UTF8,或者把数据库里的中文数据编码统一改成UTF-8。这个改动涉及存量数据,要谨慎评估。
7.3 迁移后的回归测试重点
迁移完成后,回归测试的重点应该放在这几个方面:
- 门诊挂号收费全流程,重点验证中文输入、特殊字符(比如患者姓名里带"·")、金额计算精度。
- 打印输出,特别是处方笺和发票,确认打印格式没有因字符集变化出现偏移。
- 多窗口并发操作,老系统升级后如果同时打开多个窗口并频繁切换,要观察有没有句柄泄漏或者GDI资源耗尽的问题。
- 报表统计,对账口径要跟旧系统做一致性比对,用同一个时间段跑一遍,数据必须完全一致。
金额计算的精度问题尤其要重视。Delphi的Double做浮点运算有精度误差,医疗系统直接跟钱打交道,处方金额、收费汇总、退费金额如果误差超过一分钱,财务对账就会出大问题。正确做法是金额字段一律用Currency类型或者DECIMAL数据库字段,绝不用Double。
7.4 一些新的Delphi特性可以顺手利用
升级到新版本后,有几个特性值得在重写或新增模块时使用。
TStringHelper和泛型容器(TList<T>、TDictionary<K,V>)能显著简化代码。老代码里经常用TStringList的Strings[i]做键值存储,配合IndexOf线性查找,数据量大了性能很差。换成TDictionary<string, Integer>后,查找时间复杂度从O(n)降到O(1),在加载药品字典、科室字典这类频繁查询的场景里体感明显。
Parallel编程库(System.Threading)可以用来做报表统计的并行计算。门诊量统计如果按科室、医生、时间段多个维度汇总,SQL直接跑可能几百毫秒,用TTask.Run把多个查询分到不同线程并行执行,总耗时能缩短一半以上。注意,Delphi的VCL界面更新必须回到主线程,并行任务里不能直接访问窗体控件,要用TThread.Queue回抛。
FireDAC的TFDLocalSQL也值得一提,它能在本地用SQL语法查询内存中的TDataSet,这个方法在门诊医生工作站里可以优雅地实现"筛选当前患者的所有历史处方"这类功能,不用每次都回数据库查。
8. 我在维护Delphi HIS系统时最想提醒的三件事
写到最后,分享三个我这些年维护门诊医生工作站踩坑后沉淀下来的经验。
第一,所有跟界面交互相关的操作,务必放在主线程,所有耗时的数据库查询,务必不要在UI事件里裸奔。一个最典型的反面例子:待诊患者列表的OnClick事件里直接写同步SQL查询,数据量大了以后,医生每点一个患者,界面冻结两三秒,患者早就抱怨"你们系统怎么这么卡"。正解是查询放后台线程,查询期间界面显示加载状态,数据回来后再回主线程刷新网格。Delphi里实现这个并不复杂,用TThread.CreateAnonymousThread就行,但很多老项目根本没做过这种改造。
第二,每一个写操作都要留痕迹。医院系统的用户操作记录非常重要,尤其是作废处方、退费、修改病历这类敏感操作。我的做法是在数据库里建一张op_log表,任何写操作都会插入一条记录,包含操作者、操作时间、操作类型、涉及的主键ID、操作前后的关键值。这些日志平时几乎没人看,但一旦发生医疗纠纷或者内部审计,它们就是最有说服力的证据。Delphi端封装一个通用的WriteOpLog函数,在业务代码的关键节点调用,初期会有点繁琐,但长期收益巨大。
第三,系统升级时永远要保留旧版本的完整可回退方案。医院信息系统的停机时间窗口非常短,一旦新版本上线后出现重大问题,必须在半小时内回滚到旧版本。我们团队的做法是每次升级前做三件事:完整备份数据库脚本、导出所有报表模板、保留旧版可执行文件的压缩包。升级窗口选在晚上门诊结束后,先备份再部署,留出至少两小时的观察期,第二天门诊开始前再确认一次运行状态。这个流程看起来笨,但保障了无数次平稳切换。
Delphi做医院管理系统,看起来是"过时技术"做"传统行业",实际上这个组合在医疗信息化领域至今仍有大量存量需求和持续迭代空间。核心不在于用的什么语言、什么数据库,而在于是否真正理解了门诊业务流程、患者流转逻辑和数据一致性要求。框架可以迁移,语言可以替换,但那些经过十多年业务验证的规则和细节,才是这套系统里最值钱的部分。