多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于PyQt5和SQLite的医药信息管理系统开发实战

基于PyQt5和SQLite的医药信息管理系统开发实战 开发这个基于Python的医药信息管理系统前后用了差不多三周时间项目自我定位就是源码数据库文档三件套一套能跑的代码、一套能恢复的建库脚本、一本能看懂能维护的技术说明。目标很纯粹——让一个中小型药房、诊所药房或者药品仓库摆脱用Excel记录药品批次、手动盯着效期、库存对不上账的混乱状态。适合两类人参考一类是刚踏入Python开发的初学者想找一个业务逻辑完整、能直接跑通的项目来练手另一类是医药流通领域的信息管理员需要一套低成本、可二次开发、数据可以长期沉淀的管理系统底子。1. 项目拆解医药信息管理系统到底要解决什么问题1.1 医药行业的信息管理痛点做这个系统之前我专门去调研过几个药房的实际场景发现大部分小规模药房的日常管理基本是台账直觉模式进货靠手写本子记录销售靠收银系统出流水库存靠每个月底人工盘点效期管理靠营业员翻箱子。这种模式有几个硬伤第一是效期失控一批药卖到过期才被注意第二是批次混乱同一种药不同批号混在一起出现质量问题要追溯的时候根本找不出来第三是库存账实不符盘点误差动辄几百上千损耗说不清。医药信息管理系统要解决的就是这三件事让效期可见、让批次可追溯、让库存可核对。这不是简单的商品进销存因为药品背后有GSP的影子——批号、批准文号、生产日期、有效期每一个都是硬字段缺一个后面都会出问题。系统设计的时候我坚持把批号、效期、库存量这三者作为核心数据模型的三根柱子围绕它们来展开所有功能。1.2 功能边界四个核心模块的规划需求梳理阶段最容易犯的错是功能越加越多最后变成一个半成品大杂烩。我最初列了十几个功能点后来一刀切收敛成四个核心模块药品基础信息管理药品名称、通用名、规格、生产厂家、批准文号、零售价、类别处方药/非处方药/保健品。采购入库管理记录入库单、供应商信息、采购数量和成本价入库后自动增加对应批次的库存。销售出库管理记录销售记录出库时优先扣减最早到期的批次的库存也就是医药行业常说的先进先出。库存盘点与预警实时查看当前库存总量和批次明细设置最低库存阈值、效期预警阈值达到条件时主动提醒。同时保留一个最基础的用户登录模块。不用把所有管理权限都做得很重但登录是必须的因为医药系统里操作留痕很重要。这五个模块就像房子的承重墙其他功能比如报表打印、数据导入导出都是后加的装饰不影响主体结构。2. 技术选型为什么是Python PyQt5 SQLite2.1 界面方案桌面端还是Web端技术选型阶段我纠结过两个方案用PyQt5做桌面端还是用Flask/Django做Web端。我做了一个对比表来理清思路对比维度PyQt5桌面端Flask/Django Web端部署难度单机安装打包成exe即可运行需要服务器、数据库、Web环境操作体验响应快适合高频录入操作受网络影响页面刷新有延迟数据安全数据留在本地相对安全需要额外配置权限和防护开发成本中等Qt布局不复杂较高前后端都要处理适用场景药房内部固定岗使用多门店、多用户远程访问医药门店的实际场景是营业员在固定电脑上高频操作查询药品、开销售单界面必须够快够顺滑。最终我选了PyQt5桌面端理由就一个——录入体验。药品销售时收银员要在几秒内完成扫码或模糊搜索、确认数量、打印小票Web页面在这种场景下的延迟是目前无法接受的。再加上单机版系统不需要考虑并发访问PyQt5完全够用。2.2 数据存储SQLite的轻量与够用数据库选型这块有人可能会直接推MySQL或者PostgreSQL但我的判断是SQLite在这类项目里反而是最优解。原因有三点。第一是零配置Python标准库自带sqlite3模块不需要额外安装数据库服务对第一次接触数据库的人来说门槛极低。第二是单机数据量完全够用一个中型药房的药品品类撑死几千条进销存流水一年也就几十万条SQLite对这种量级的数据游刃有余。第三是文件的形态让备份和迁移变得异常简单——数据库就是一个.db文件复制走就是完整备份。当然如果系统将来要扩展到多个门店、多台电脑同时操作SQLite的并发能力就会成为瓶颈。我预留了一个扩展路径数据访问层全部走DAO模式Data Access Object后续要迁移到MySQL只需要替换数据访问层的实现业务逻辑代码不用动。这也是项目文档里反复强调的一个设计原则——数据库不能锁死在一种实现上。2.3 工程架构三层分离别把所有代码堆进窗口很多初学者的Python桌面项目打开就是一个巨大的main.py里面混着界面代码、SQL语句、业务逻辑改一个字段名要翻半天文件。这个项目我严格按三层架构来组织源码界面层ui/存放Qt界面文件和窗口逻辑只负责显示和接收用户操作。业务逻辑层service/处理业务规则比如入库后更新库存、出库时的批次扣减策略、效期预警的判定逻辑。数据访问层dao/封装所有数据库操作每个表对应一个基础数据访问类。这样分层的好处用一句话说界面改了不动逻辑逻辑改了不动SQL表结构调整只影响数据访问层。比如入库单界面从QTableWidget换成QTableView只是ui层的事再比如出库策略从先进先出改成指定批次出库只是service层的事。项目后来要加一个药品利润统计功能我只往service里加了一个方法数据库层面没有任何改动这就是分层带来的实际收益。3. 数据库设计医药业务的核心是批次与效期3.1 药品主表的设计细节药品主表是整个系统的地基字段设计直接决定后续所有功能能不能顺畅跑起来。我设计的medicine表核心字段如下CREATE TABLE medicine ( id INTEGER PRIMARY KEY AUTOINCREMENT, medicine_code TEXT UNIQUE NOT NULL, -- 药品编码业务主键 name TEXT NOT NULL, -- 商品名 generic_name TEXT NOT NULL, -- 通用名 spec TEXT, -- 规格 dosage_form TEXT, -- 剂型 manufacturer TEXT, -- 生产厂家 approval_no TEXT, -- 批准文号 category TEXT DEFAULT OTC, -- 处方药/非处方药/保健品 retail_price REAL NOT NULL DEFAULT 0, -- 零售价 purchase_price REAL NOT NULL DEFAULT 0, -- 采购参考价 min_stock REAL DEFAULT 10, -- 最低库存预警值 status TEXT DEFAULT on, -- 停用/启用 created_at TEXT DEFAULT (datetime(now, localtime)) );这里比较关键的设计决策是不直接用自增的id作为药品编码而是单独设计一个medicine_code业务编码字段。因为id只是数据库内部的唯一标识一旦数据导入导出或者从其他系统迁移id很可能冲突而medicine_code承载了实际业务含义——比如前缀代表分类、后面跟上序列号。现实中药品审计、追溯都需要稳定的业务编码不能用内部自增id来充当。另一个细节是零售价和采购价我刻意分开存。很多初学项目只有一个价格字段后面做利润统计的时候就会傻眼。分开存储看似多一个字段实际上为将来的毛利分析、价格调整记录留了空间。药品价格是变动频繁的业务数据系统上线后一定会面临改了新价格但想查旧价格的需求所以我在文档里特别说明如果需要完整的价格历史建议再加一张medicine_price_history表当前版本的price只是最新快照。3.2 库存表同一个药品不同批号必须分开医药进销存和普通商品进销存最大的不同就在这里普通商品库存可以按SKU汇总药品库存必须按药品 批号维度拆分。原因很简单同一药品不同批号的效期不一样价格也可能不一样一旦按汇总方式管理先进先出就无从谈起。这是我设计的stock表CREATE TABLE stock ( id INTEGER PRIMARY KEY AUTOINCREMENT, medicine_code TEXT NOT NULL, batch_no TEXT NOT NULL, -- 生产批号 production_date TEXT, -- 生产日期 expiry_date TEXT NOT NULL, -- 有效期至 quantity REAL NOT NULL DEFAULT 0, -- 当前批次库存数量 supplier_id INTEGER, -- 供应商 last_in_date TEXT, UNIQUE(medicine_code, batch_no) );这里的UNIQUE约束加上medicine_code batch_no联合唯一从数据库层面保证同一药品同一批号只有一条库存记录。入库时先检查这条记录是否存在存在就update数量不存在就insert新行。出库时按效期从近到远排序优先扣除最早到期的批次。这个逻辑完全对应药店实际的操作规则——先卖快过期的药不是为了省事是为了减少效期损耗。此外药品销售、退货、报损等所有涉及库存变动的操作我全部要求经过一个统一的库存变动记录表。之前我偷懒过直接在stock表上做加减结果某个入库单重复提交了一次库存悄悄多了一半花了一下午查原因。后来加了stock_log表任何变动都留痕账户平不平一目了然CREATE TABLE stock_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, medicine_code TEXT NOT NULL, batch_no TEXT, change_type TEXT NOT NULL, -- in/out/return/loss/check change_qty REAL NOT NULL, before_qty REAL, after_qty REAL, operator TEXT, remark TEXT, create_time TEXT DEFAULT (datetime(now, localtime)) );3.3 关联表供应商、入库单、销售单围绕药品主表还需要配套的关联表。供应商表比较简单记录名称、联系人、电话、地址入库单表记录每次采购的时间、经手人、供应商入库单明细表记录该次入库涉及的药品、批号、数量、采购价。销售单表同理但销售单不涉及一个销售明细的批次概念因为销售明细需要记录卖出的哪个批号的哪一批药品这样才能和库存表形成完整的追溯链条。我把关键外键关系在文档里画了一个简单的文字版关系图不用画图工具用文字描述就够供应商1对多入库单入库单1对多入库明细入库明细多对一药品表和批次库存销售单1对多销售明细销售明细多对一批次库存。这套关系带来的直观效果是任何一个药品从供应商进货到销售给顾客双向都能查得到。质检抽到某一批号有问题的药系统里筛一下就能列出这批药进过多少、卖了多少、还剩多少、都卖给了谁。这是医药系统价值的核心体现。4. 核心功能落地从登录到进销存的完整实现4.1 用户登录与操作留痕登录模块看似简单我仍然坚持做了三件事密码不存明文、登录记录写日志、超过3次失败就锁定账户30分钟。密码加密用的是hashlib的sha256加盐处理不做解密只比对哈希值。有人可能觉得单机系统没必要较真但医药系统涉及敏感数据账号管理必须有个底线。登录界面的逻辑用PyQt5写起来很简单核心代码大约半屏但要注意一个小坑QLineEdit的密码模式要设置成setEchoMode(QLineEdit.Password)否则录入的密码会明文显示在屏幕上。数据库里还用一张user_log表记录登录时间、操作内容、操作对象这样一旦出现数据异常可以追溯是哪位操作者在什么时间做了什么操作。后面系统上线遇到一笔销售数据对不上正是靠这张操作日志表定位到了问题。4.2 药品信息查询模糊搜索与精确命中药品查询是使用频率最高的功能。现实场景里营业员不一定记得完整药名更多是输入阿莫西林胶囊或者模糊的阿莫西林阿莫这种前缀词。所以我没有用单一的WHERE name ?精确匹配而是组合了LIKE模糊查询。这里要特别提醒SQLite的LIKE默认对中文是支持的但要注意大小写敏感问题——英文药品名比如Vitamin C如果不区分大小写会漏查。实现上我在service层写了一个build_query方法根据用户输入决定拼接条件全部采用参数化查询。参数化这个习惯我从项目第一天就坚持原因下面会专门讲。界面上采用了表格实时刷新的思路每次输入框文本变化就重新执行一次查询体验上就是边打字边出结果。这个交互模式在PyQt5里通过QLineEdit的textChanged信号实现比每次点查询按钮高效得多。4.3 药品入库出库的库存联动入库的核心逻辑就一句SQL的问题先查stock表有没有medicine_code batch_no的记录有则累加数量无则插入新行。但一定要放在事务里执行否则重复点击提交按钮可能导致两次完整入库流程都执行。我在入库按钮的响应函数开头直接调用了数据库连接对象的begin()在确认写入成功后commit()任何异常则rollback()。出库的实现比入库复杂得多因为要用到先进先出策略。具体做法是提交销售单的时候按照传入的药品编码查出所有该药品在当前库存中、效期从近到远排序的批次列表然后依次扣减def deduct_stock(medicine_code, need_qty): rows stock_dao.find_by_medicine_code(medicine_code, order_byexpiry_date ASC) remaining need_qty for row in rows: if remaining 0: break available row[quantity] if available remaining: stock_dao.decrease(row[id], remaining) remaining 0 else: stock_dao.decrease(row[id], available) remaining - available if remaining 0: raise BusinessException(f库存不足缺少 {remaining} 件)这里有个业务细节值得展开讲同一药品、同一批号在不同时间入库可能价格不同。先进先出策略下销售成本核算要按批次单价来算而不是简单用药品主表的采购参考价。我在销售明细表里冗余了一个cost_price字段记录实际扣减的那个批次对应的采购单价。这样财务报表才能算清楚毛利不然卖一盒赚多少钱永远说不清。这个字段的冗余设计是医药进销存系统里很值得标注的一个经验点。4.4 效期预警与库存阈值预警预警功能是这个系统区别于普通进销存的一个亮点。实现不复杂关键在于阈值设计。我在setting表里存了两个配置项效期预警提前天数默认90天、低库存预警开关默认开启。所有逻辑都封装在service层的check_alerts方法里运行后返回一个预警清单列表界面端只需要渲染列表即可。效期预警的SQL核心是SELECT medicine_code, batch_no, expiry_date, quantity FROM stock WHERE quantity 0 AND expiry_date date(now, localtime, 90 days) AND expiry_date date(now, localtime) ORDER BY expiry_date ASC;低库存预警则是对所有药品查询当前库存总量与min_stock比较。注意这里必须用SUM(quantity)按medicine_code汇总因为同一药品有多个批次的库存不能只看其中某一条记录。我还给预警列表加了等级标识库存为零显示红色、库存低于最低值显示橙色、效期三个月内显示黄色。药店员工每天上班打开系统第一眼看到的就是这个预警面板比翻台账高效太多。4.5 图表与统计报表最后一块是简单的统计功能我用matplotlib直接在窗口内嵌了3张图表近7天销售趋势折线图、本月药品销售额Top10柱状图、当前库存类别占比饼图。matplotlib的FigureCanvasQTAgg嵌入PyQt5窗口是非常成熟的做法这里不再赘述代码。但有一个痛点处理了我挺久matplotlib默认字体不支持中文图表的标题和图例全部成了方块。解决方案是在代码开头指定中文字体路径import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False如果你打包后在别的电脑上运行可能还需要把字体文件一并打包或者改用系统自带字体的名称列表。这个坑我放在后面问题排查部分再详细说。5. 实操避坑开发过程中踩过的关键坑5.1 中文乱码界面和数据库两头夹击中文乱码是Python桌面项目里几乎人人都会遇到的问题。PyQt5的QString和Python的str类型在正常情况下的数据往返没有问题但当数据进了SQLite再从库里读出来就容易出现编码错误。核心原因往往不在Qt界面而在SQLite驱动的编码声明上。Python标准库sqlite3默认以UTF-8读写文本只要创建表时的中文内容是用UTF-8写入通常不会有问题。最常见的意外是直接从其他系统导出的CSV数据用GBK编码硬塞进SQLite后读出来就变成了乱码。我的排查经验是三步走先查SQLite数据本身是否正确用DB Browser for SQLite打开db文件看中文内容再确认Python连接时的connect参数是否显式声明了UTF-8最后检查PyQt控件编码。大多数项目问题出在数据进入数据库之前就已经是错误的编码界面只是忠实呈现错误而已。5.2 日期处理字符串还是时间戳日期字段是我在项目文档里反复强调的部分。SQLite不像MySQL那样有独立的datetime类型它只能用TEXT、REAL、INTEGER三种方式存日期。我用的是TEXT并且统一格式化为YYYY-MM-DD。这样做的好处是字符串比较和日期比较结果一致可以直接用在SQL的WHERE条件里做范围查询。但你必须严格要求自己在写入端就格式化好一旦混入YYYY/MM/DD或者YYYY-M-D这种不标准写法排序和比较就会出问题。另一个经验是把录入日期和业务日期分开。入库单的create_time用系统当前时间datetime(now,localtime)自动生成但采购单的实际采购日期应该让用户手动填写。不要为了省事直接把系统时间当业务时间采购员补录前一天入库单的情况太常见了。5.3 SQL注入参数化查询是底线医药系统里的数据敏感程度很高任何SQL注入漏洞都可能造成严重后果。我见过太多初学项目直接在代码里做字符串拼接SELECT * FROM medicine WHERE name keyword 。一旦keyword里包含单引号和SQL关键字整个查询条件就可能被改写。我全程使用参数化查询用?占位符来传值cursor.execute(SELECT * FROM medicine WHERE name LIKE ?, (% keyword %,))这个习惯带来的额外好处是参数值会被SQLite驱动自动转义中文、引号、特殊字符都不会破坏SQL结构。在代码审查阶段我给自己定的标准是整个项目代码中不允许出现一次通过format或f-string拼接SQL语句的地方发现就要重构。这是一个医疗数据系统必须守住的底线。5.4 打包发布PyInstaller的黑盒问题项目开发完成之后最终交付给需求方的是一个可以直接运行的exe文件。我用PyInstaller打包但第一次打包出来的exe一运行就报错而且是运行时才报错不是打包时报错。排查之后发现问题主要集中在三方面。第一是Qt插件缺失。PyInstaller默认会收集PyQt5的大部分插件但如果你的开发环境里装了几百MB的Qt库打包时有些插件没有被自动收集运行到某些控件时就会崩溃。解决方法是使用PyInstaller的--collect-all PyQt5参数或者手动在spec文件里添加插件路径。第二是matplotlib的字体和缓存问题。打包后运行时matplotlib需要写入临时缓存目录如果程序运行在无写权限的目录下比如管理员账户以外的用户初始化就会失败。我在代码开头兼容性处理了一段import os os.environ[MPLCONFIGDIR] os.path.join(tempfile.gettempdir(), mpl_config) if not os.path.exists(os.environ[MPLCONFIGDIR]): os.makedirs(os.environ[MPLCONFIGDIR])第三是exe的运行目录问题。双击exe时当前工作目录可能不是exe所在目录导致找不到db文件和配置文件。我在入口代码里统一把工作目录切换到exe所在路径用os.chdir(os.path.dirname(sys.executable))并判断如果是源码运行非打包环境则用os.path.dirname(__file__)。这个细节不处理用户会莫名其妙遇到数据库打不开的报错。5.5 数据备份数据库文件不是万无一失SQLite的数据库就是一个文件简单备份文件就能做到备份但这带来的隐患是如果系统正在写入时直接复制db文件得到的备份可能是不完整的因为SQLite的WAL模式或并发写入可能导致文件处于中间状态。稳妥的做法是使用SQLite自带的在线备份APIimport sqlite3 source sqlite3.connect(medicine.db) dest sqlite3.connect(backup.db) source.backup(dest)我在系统里做了一个定时备份的功能每天营业结束后自动备份到backup目录文件名带日期。医药进销存系统的数据是连续积累的一点点损坏都可能影响几个月对账。现在我每天收到备份目录里有几十个日期文件心里才踏实。6. 实操总结最后说一点我个人在整个开发过程中感受最深的地方医药信息管理系统这类业务型软件真正难的不是Python语法、不是PyQt5控件、也不是SQL写法而是对业务场景的理解是否深入。你不理解先进先出就做不出合理的库存扣减逻辑你不理解批号效期管理就设计不出stock表的联合唯一约束你不理解操作留痕的重要性就想不到除了业务表之外还要有日志表。技术只是工具业务才是灵魂。如果你想把这套系统的代码真正消化掉我的建议是从数据库开始读先看建库脚本搞清楚每张表之间的关系然后在Python里运行一次完整的入库、出库、盘点流程对照stock_log表的变化理解每一步。最后再做二次开发的时候优先做销售统计的报表——它会倒逼你理清业务数据之间最底层的关联。整个系统后续如果要扩展我打算优先添加的是会员管理和处方登记功能前者是零售业务增长的关键后者是处方药合规销售的未来标配这两个方向都有不少文章可做。
返回列表