尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

药品仓储巡检系统实战:双框架架构与批次效期管理

药品仓储巡检系统实战:双框架架构与批次效期管理 1. 药品仓储巡检到底要巡什么需求调研阶段的关键发现去年年中我接手这个项目时甲方提的需求特别简单——“做一个药品仓库的巡检系统”听起来像是那种随手就能交付的管理小工具。但真正蹲到仓库现场待了两天之后我才意识到这事远没有标题看起来那么轻巧。药品仓储巡检的本质是“效期安全”和“质量追溯”它不仅是一套业务流程还牵扯到监管合规、批次追溯、温湿度记录、异常召回等多个环节。先还原一下现场仓库里几万板药品按照货位码一行行排开每个货位上挂着批次卡上面写着品名、批号、生产日期、有效期。巡检员每天要按区域巡检一次把近效期药品、破损包装、温湿度异常、账物不符这些问题记录在纸质表格上回来再录入Excel。整个过程有两个致命问题第一纸质记录和Excel是两套数据月底对账全靠人工经常对不上第二效期计算完全靠人盯一旦漏掉近效期批次轻则滞销报损重则被监管查到“过期药品未及时处置”。所以在正式设计系统前我花了大半天梳理核心业务对象总结下来是四类药品档案与批次库存同一通用名药品可能对应多个厂家、多个批号效期按批号计算而不是按品名计算。货位与仓区管理仓库分为合格品区、待验区、不合格品区、退货区巡检路径按物理区域划分。巡检任务与明细一个巡检任务包含很多条明细每条对应“某个货位上的某批次药品”巡检结果是合格或异常。异常记录与处理闭环发现问题不能只是登记还要有处理状态流转比如“待复核”“已下架”“已报损”。这里我特别想说做管理系统的第一步永远是梳理业务对象而不是选框架。我在这个项目里先用了两天画业务流程图和数据字典后面写代码几乎没有返工。很多团队一上来就建表表建得歪七扭八后面CURD写得再多也是负资产。药品巡检还有一个特殊点就是它必须留下“证据链”。什么时间、谁、在哪个货位、检查了哪个批次、结果如何、如果异常怎么处理的这些信息必须完整记录且不能随意修改。这意味着数据库设计里要区分“业务表”和“流水表”巡检明细属于流水只能追加不能更新。有了这个认知基础再去谈技术选型就不是纸上谈兵了。下面我说说为什么这个项目最终成了“ThinkPHP Laravel 双框架混编”的架构以及两套系统各自负责什么。2. 双框架并存的架构理由ThinkPHP 管后台Laravel 管巡检服务很多读者看到标题第一反应是一个系统为什么要用两个PHP框架这不是给自己找麻烦吗说实话如果在完全绿地项目里我也不会开这个头。但这个项目有非常现实的历史背景——**公司原有的进销存系统是用 ThinkPHP 5.1 开发的里面沉淀了完整的药品档案、批次入库、采购退货、销售出库等核心数据不可能推倒重来。而这次新增的巡检业务模块由一支更熟悉 Laravel 的开发小组来负责。**两块系统需要无缝协作最终就形成了双框架共存的局面。架构划分方式如下系统端使用框架核心职责后台管理端PC WebThinkPHP 5.1药品档案、批次库存管理、用户权限、基础数据维护、报表查询巡检服务端APILaravel 8巡检任务派发、PDA扫码提交、异常记录流转、温湿度数据采集、消息通知前端展示后台用传统模板引擎巡检用Vue Element UIPDA端适配手机浏览器这样划分的根本原因是数据归属和性能要求不一样。库存、入库、出库这类操作对事务一致性要求极高必须跑在原有的 ThinkPHP 系统里直接操作原有的表结构不敢乱动而巡检任务派发、扫码提交这类操作是典型的“高并发小事务”PDA 扫码一瞬间可能几十个任务同时提交用 Laravel 的队列和 API 资源层来承载更舒服。两个框架之间通过 HTTP API 通信Token 鉴权用 JWT数据格式统一为 JSON。ThinkPHP 老系统只暴露只读接口如批次信息查询、库存校验Laravel 巡检系统通过 Guzzle 发起请求拿到基础档案数据后写入自己的本地库或直接展示。为什么会选择这个方案而不是“在 ThinkPHP 里直接加模块”或者“用 Laravel 重写全部”原因有三点风险隔离进销存系统不能断老框架的升级成本极高与其冒险重构不如让它继续稳定运行。新业务跑在新框架上出问题影响面可控。团队技能复用公司后端团队本来就分两组一组熟悉 TP一组主攻 Laravel。这样的分工刚好让两边都不需要跨界学习交付节奏更快。部署独立Laravel 巡检服务独立部署在一台轻量服务器上Redis、队列这些扩展能力可以放心使用不会影响到老系统所在的 Windows Apache 环境。当然双框架也带来额外复杂度比如两套系统的代码仓库、两套部署流程、两套日志体系都靠统一规范来拉齐。这块我在第 5 章会展开讲如果你也想搞类似的混编架构那些坑基本都是绕不开的。3. 数据建模与表结构批次效期才是药品巡检的灵魂数据模型是整个系统的地基这一章我直接给出核心表设计以及背后的设计考量。药品行业的巡检系统所有功能最终都要落在“批次”上而不是“品名”上。3.1 两张核心业务表的设计思路第一张表是drug_batch药品批次表它从老进销存系统同步而来关键字段如下CREATE TABLE drug_batch ( id int(11) unsigned NOT NULL AUTO_INCREMENT, drug_code varchar(32) NOT NULL COMMENT 药品编码, drug_name varchar(128) NOT NULL COMMENT 药品通用名, specification varchar(64) DEFAULT NULL COMMENT 规格, manufacturer varchar(128) DEFAULT NULL COMMENT 生产厂家, batch_no varchar(64) NOT NULL COMMENT 生产批号, production_date date DEFAULT NULL COMMENT 生产日期, expiry_date date NOT NULL COMMENT 有效期至, stock_quantity int(11) NOT NULL DEFAULT 0 COMMENT 当前库存数量, position_code varchar(32) DEFAULT NULL COMMENT 货位编码, storage_zone varchar(32) DEFAULT qualified COMMENT 货区: qualified-合格品区, unqualified-不合格品区, pending-待验区, returned-退货区, temperature_lower decimal(5,2) DEFAULT NULL COMMENT 储存温度下限, temperature_upper decimal(5,2) DEFAULT NULL COMMENT 储存温度上限, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_batch_no (batch_no), KEY idx_expiry_date (expiry_date), KEY idx_position_code (position_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT药品批次表;这里有两个索引值得注意idx_expiry_date用于近效期筛选巡检计划生成时高频查询“哪些批次在未来 6 个月过期”idx_position_code用于按货位检索巡检明细。第二张表是inspection_task巡检任务表和inspection_task_item巡检任务明细表一主一从的标准设计CREATE TABLE inspection_task ( id int(11) unsigned NOT NULL AUTO_INCREMENT, task_no varchar(32) NOT NULL COMMENT 任务编号, task_type tinyint(1) NOT NULL DEFAULT 1 COMMENT 1-日常巡检, 2-近效期专项, 3-温湿度专项, 4-盘点复核, warehouse_code varchar(32) NOT NULL COMMENT 仓库编码, assign_to int(11) DEFAULT NULL COMMENT 负责人UID, inspection_cycle varchar(16) DEFAULT daily COMMENT daily-每日, weekly-每周, monthly-每月, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0-待执行, 1-执行中, 2-已完成, 3-已取消, deadline datetime DEFAULT NULL COMMENT 截止时间, remark varchar(255) DEFAULT NULL, created_by int(11) NOT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_task_status_deadline (status, deadline) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT巡检任务表;明细表的核心是记录“某一批药品在某次巡检中的结果”。字段包含task_id、batch_id、batch_no、position_code、inspection_result1-正常2-异常、exception_type、exception_desc、image_urls、inspector_id、inspected_at等。每条明细就是检查动作的留痕记录之后追溯某批药品在某天的巡检状况直接按batch_no inspected_at索引查询即可。3.2 温湿度记录和异常闭环的表结构药品仓储的另一条生命线是温湿度。常温库要求 10~30 摄氏度阴凉库不超过 20 摄氏度冷库则要求在 2~8 摄氏度。每一次巡检都要同步记录温湿度数据我单独建了storage_environment_log表包含warehouse_code、temperature、humidity、record_type1-自动采集2-人工录入、recorded_at、recorder_id。异常记录的闭环表inspection_exception是另一个关键点它不能简单写在明细表里加个状态因为一个异常可能要经历多条处理动作。我的设计是主表记录异常发生时间、类型、等级、关联的明细ID、处理状态另外建一张exception_handle_log流水表记录每一步操作下架、复核、报损、销毁每一步操作必须有操作人、操作时间、结果说明。设计这块时我有一个心得巡检系统的核心价值在于“审计追溯”一切数据都只追加、不覆盖。如果一个巡检员填错了结果不是去 UPDATE 原记录而是再追加一条“复核修正”记录。这在药品监管审计时非常有用。4. 巡检主流程的实现拆解从任务下发到PDA扫码复核整个巡检流程可以拆成五个阶段生成任务、领取任务、执行扫码、提交结果、异常处理。下面我按这个顺序把实现细节和关键代码讲清楚。4.1 巡检任务的自动生成任务不是每次人工点“新建”生成的而是用 Laravel 的调度器app/Console/Kernel.php里的schedule方法每天凌晨自动生成当天任务。生成逻辑是根据仓库仓储区域配置获取需要巡检的货位列表。查询每个货位上所有批次筛选出满足巡检要求的批次近效期6个月以内的必检、库存数量大于0的全部抽检、上次巡检异常过的批次必须复检。按任务类型组装巡检明细批量写入inspection_task_item。核心的筛选代码类似这样// 近效期批次优先纳入巡检 $nearExpiryBatches DrugBatch::whereBetween(expiry_date, [Carbon::today(), Carbon::today()-addDays(180)]) -where(stock_quantity, , 0) -get(); // 上次巡检异常的批次必须复检 $exceptionBatchIds InspectionTaskItem::where(inspection_result, 2) -whereIn(task_id, Task::where(created_at, , Carbon::yesterday())-pluck(id)) -pluck(batch_id) -unique();这里有一个非常坑的细节同一个批次的库存可能分散在多个货位不能简单以drug_batch表的position_code为准而是要单独建一张“批次货位库存关系表”一张批次对应多行货位库存。如果忽略了这一点巡检员在A货位扫码时可能查到本应放在B货位的批次造成账物不符。4.2 PDA扫码与离线缓存巡检员在 PDA 上登录后默认展示“今日待办任务”。点进任务后进入扫码界面。我用的是 HTML5 调用摄像头扫码之所以没用原生扫码插件是因为公司的PDA是低端安卓设备装不了太多APPH5方案部署简单、跨设备兼容性好。扫码时的核心逻辑是扫描商品条形码 → 根据条码解析出药品编码 → 再按当前任务下的明细匹配批次 → 回显该批次的效期、数量、货位信息 → 巡检员确认后选择“正常”或“异常”。商品条码里面通常只包含药品编码不包含批次信息所以扫码后还要让巡检员选择具体批号。这一步特别容易出操作错误我的处理方式是自动将“同编码 近效期”的批次排在列表前面并默认高亮巡检员只需确认即可将误选概率降到最低。在冷库区域信号不好所以 Laravel 服务端做了一个简易的离线缓存方案PDA 在进入任务时一次性把扫码所需的基础数据全部拉下来存在localStorage巡检动作在本地完成统一标记为“待同步”等信号恢复或回到办公区后一键批量提交。这个方案比复杂的长连接和离线队列简单太多了实测稳定可靠。批量提交的 Laravel 控制器实现如下public function batchSubmit(Request $request) { $items $request-input(items, []); if (empty($items)) { return $this-fail(提交内容不能为空); } DB::transaction(function () use ($items) { foreach ($items as $item) { TaskItem::where(id, $item[item_id]) -where(status, 0) -update([ inspection_result $item[result], exception_type $item[exception_type] ?? , exception_desc $item[exception_desc] ?? , inspected_at Carbon::now(), ]); } }); return $this-success(巡检结果提交成功); }这里要特别提醒批量提交必须用数据库事务并且要加乐观锁条件where(status, 0)防止重复提交时同一明细被覆盖两次。我们的 PDA 偶尔会出现网络超时后用户连续点两次提交按钮的情况如果没有状态判断就可能造成异常记录被正常记录覆盖掉而这条数据恰恰是审计时最需要的。4.3 异常处理的闭环巡检发现异常后系统会自动推送消息给仓储主管同时生成一张待处理异常单。异常单的处理流程有两种路径轻微异常如包装轻微破损主管直接在系统里登记“下架待验”药品从合格品区移到待验区异常单关闭。严重异常如疑似污染、效期过期必须走“下架 → 停售 → 质量复核 → 报损/销毁”流程每一步都要拍照上传。前端用 Vue 实现了一个异常处理进度条后端接一个状态机每一步的流转都由 Laravel 的FormRequest做严格的字段校验。我这里用 Laravel 的状态机是因为它的事件监听机制很顺手——每变更一次状态都可以触发一个事件去写日志、通知相关人。如果放在 ThinkPHP 老系统里这些逻辑就得靠手写 if-else会很混乱。5. 双框架联调最容易踩的坑鉴权、事务、时区与队列双框架混编听起来很酷生产环境跑起来才知道麻烦。我这篇文章里最想让你记住的就是这一章节的内容。下面四个坑全都是我这个项目真实踩出来的。5.1 JWT 鉴权的兼容问题巡检服务用 Laravel 的tymon/jwt-auth签发 TokenThinkPHP 老系统的接口要校验这个 Token。两边不能直接共用一套 JWT 类库但签名算法是一致的所以我的做法是在 ThinkPHP 公共函数里写了一个verifyJwt()方法用firebase/php-jwt来解码校验。关键在于.env里两边配置的JWT_SECRET必须完全一致且JWT_ALGO都设为HS256。如果某一天你发现老系统接口偶尔能调通、偶尔 401大概率是两边系统时间不同步导致nbfnot before或expexpiration校验失败。我排查过这个问题最后是在老系统的定时任务里加了一个校时脚本才彻底解决。5.2 跨库事务没法用巡检服务在建库时我特意把表建在了独立数据库inspection_db没有跟老系统的erp_db混在一起。这带来一个麻烦一个完整的业务操作可能要同时更新两个库但数据库层面没法做跨库事务。举个例子巡检员在 PDA 上确认“某批次下架”前提是库存数量必须足够。这涉及两步Laravel 库写入异常记录ThinkPHP 库扣减可用库存。如果第一步成功、第二步失败了数据就处于不一致状态。我的解决方案是引入本地消息表 定时补偿任务在 Laravel 库建一张outbox_message表记录待同步的业务消息比如“批次下架待扣减”。先把业务操作和消息写入放在同一个数据库事务里保证本地一致性。然后由 Laravel 的schedule定时任务读取未发送的消息调用 ThinkPHP 库存接口。ThinkPHP 接口处理成功后在消息表标记完成失败则进入重试队列并触发告警。这种方式其实是一个轻量版的“本地消息表”模式它是我能想到的成本最低且可靠的跨库一致性方案生产环境跑了几个月没有出过数据不一致的问题。5.3 时区设置不一致这是一个极小但影响极大的细节。ThinkPHP 老系统在配置里没有设置时区默认是PRC东八区Laravel 的app.php里timezone也设置为Asia/Shanghai。看起来一致但我建议你在两个框架里都做一次显式声明。我踩过的问题是Laravel 的Carbon::now()默认用的是应用时区但 MySQL 连接配置里如果没加timezone 08:00写入数据库的时间会和本地时间相差 8 小时。尤其在上传温湿度数据时冷库自动采集设备用的是 UTC 时间如果不转换数据曲线整个是错位的。排查半天最后发现只是 PDO 连接串少了一段。5.4 队列任务在双框架中的分工Laravel 的队列很好用我几乎把所有耗时操作都丢进了队列批量生成巡检任务、推送微信公众号通知、同步基础数据、导出Excel。但 ThinkPHP 老系统没有队列组件它的耗时代理怎么处理我的做法是在老系统里装了一个 Redis 扩展然后用一个独立的消费脚本shell PHP CLI去轮询 Redis 队列。Laravel 这边任务完成后把结果推送到 Redis老系统消费。虽然不够优雅但好在老系统的队列场景非常少只有两种同步库存和更新报表。用命令行的方式做反而比在 Apache 进程里跑长任务更稳定。这里有个建议双框架联调接口文档一定要先行。我们使用 Swagger 统一维护 API 文档双方开发按照文档并行推进联调时问题少了很多。前期省下的时间最后都在联调期还了回去。6. 上线后的真实教训性能、数据一致性与使用习惯改造系统上线只是开始真正考验是后续三个月的运行期。这一章我挑三个最有代表性的问题说都是排查成本比较高的。6.1 巡检任务批量生成的性能瓶颈第一个版本生成巡检任务时我直接用foreach逐条插入inspection_task_item。几百个批次没问题但等到库里有几万批次时一次周检要生成两万多条明细接口直接超时。优化方案是两条腿走路用 Laravel 的chunkById()分批读取批次不一次性加载全表。改用批量插入DB::table(inspection_task_item)-insert($rows)而不是 Eloquent 逐条保存。实测效果非常明显两万条明细的生成从原来的 40 多秒降到了 3 秒以内。这是因为 Eloquent 模型每次保存都要走一遍事件、时间戳、属性转换而查询构造器的批量插入只是简单的INSERT语句。代价是不能在插入时自动写入created_at需要在代码里手动补上。// 分批读取 批量插入的简化示例 $now Carbon::now(); $rows []; $batchQuery DrugBatch::where(stock_quantity, , 0)-orderBy(id); $batchQuery-chunkById(1000, function ($drugBatches) use ($rows, $taskId, $now) { foreach ($drugBatches as $batch) { $rows[] [ task_id $taskId, batch_id $batch-id, batch_no $batch-batch_no, position_code $batch-position_code, inspection_result 0, status 0, created_at $now, updated_at $now, ]; } if (count($rows) 2000) { DB::table(inspection_task_item)-insert($rows); $rows []; } }); // 收尾插入剩余不足 2000 条的数据 if (!empty($rows)) { DB::table(inspection_task_item)-insert($rows); }6.2 温湿度数据的海量写入优化自动温湿度记录仪每 5 分钟上报一次数据一个冷库一天就是 288 条记录三个冷库一个月将近 2.6 万条。这个量级不算大但配合业务增长后一年内会迅速膨胀到百万级。我在storage_environment_log表上加了分区按月份分区查询温湿度趋势时只扫描当月分区性能提升明显。另外一个优化点是不要为了排错而过度索引。温湿度记录表原本为了支持各种统计查询加了四个索引结果写入性能明显下降。后来我保留了recorded_at单列索引作为唯一索引其余统计查询走定时汇总表才把事情理顺。6.3 使用习惯改造从“抗拒扫码”到“离不开扫码”最后聊一个非技术问题。系统上线第一周巡检员普遍抵触以前用纸质表格打勾只要五分钟现在扫码、确认、提交步骤翻倍再加上PDA又大又重他们宁愿回到老办法。我的应对策略是改了两个小地方第一把PDA网页界面的大按钮做得极其夸张字号 32px 以上按钮之间的间距加大戴手套戴眼镜都能点准第二在扫码后自动播报语音“某某批次有效期至某年某月正常”这样巡检员甚至不用看屏幕。这两件事做完操作效率基本追平纸质表格抵触情绪很快就消了。如果需要复制这个项目的思路我建议你别照搬我的架构而是先想清楚自己的业务体量单仓库几百个批次其实一个 ThinkPHP 就能搞定多仓库、多药品、强合规场景才需要上双框架乃至微服务的拆分。架构是业务的影子不是说越复杂越好。这个项目能顺利落地归根到底不是 Laravel 或者 ThinkPHP 的功劳而是需求理得清楚、边界划得明白、细节抠得足够狠。
返回列表