news 2026/9/18 4:02:07

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
药品仓储巡检系统实战:双框架架构与批次效期管理

1. 药品仓储巡检到底要巡什么:需求调研阶段的关键发现

去年年中我接手这个项目时,甲方提的需求特别简单——“做一个药品仓库的巡检系统”,听起来像是那种随手就能交付的管理小工具。但真正蹲到仓库现场待了两天之后,我才意识到这事远没有标题看起来那么轻巧。药品仓储巡检的本质是“效期安全”和“质量追溯”,它不仅是一套业务流程,还牵扯到监管合规、批次追溯、温湿度记录、异常召回等多个环节。

先还原一下现场:仓库里几万板药品按照货位码一行行排开,每个货位上挂着批次卡,上面写着品名、批号、生产日期、有效期。巡检员每天要按区域巡检一次,把近效期药品、破损包装、温湿度异常、账物不符这些问题记录在纸质表格上,回来再录入Excel。整个过程有两个致命问题:第一,纸质记录和Excel是两套数据,月底对账全靠人工,经常对不上;第二,效期计算完全靠人盯,一旦漏掉近效期批次,轻则滞销报损,重则被监管查到“过期药品未及时处置”。

所以在正式设计系统前,我花了大半天梳理核心业务对象,总结下来是四类:

  • 药品档案与批次库存:同一通用名药品可能对应多个厂家、多个批号,效期按批号计算而不是按品名计算。
  • 货位与仓区管理:仓库分为合格品区、待验区、不合格品区、退货区,巡检路径按物理区域划分。
  • 巡检任务与明细:一个巡检任务包含很多条明细,每条对应“某个货位上的某批次药品”,巡检结果是合格或异常。
  • 异常记录与处理闭环:发现问题不能只是登记,还要有处理状态流转,比如“待复核”“已下架”“已报损”。

这里我特别想说:做管理系统的第一步永远是梳理业务对象,而不是选框架。我在这个项目里先用了两天画业务流程图和数据字典,后面写代码几乎没有返工。很多团队一上来就建表,表建得歪七扭八,后面CURD写得再多也是负资产。

药品巡检还有一个特殊点,就是它必须留下“证据链”。什么时间、谁、在哪个货位、检查了哪个批次、结果如何、如果异常怎么处理的,这些信息必须完整记录且不能随意修改。这意味着数据库设计里要区分“业务表”和“流水表”,巡检明细属于流水,只能追加不能更新。

有了这个认知基础,再去谈技术选型就不是纸上谈兵了。下面我说说为什么这个项目最终成了“ThinkPHP + Laravel 双框架混编”的架构,以及两套系统各自负责什么。

2. 双框架并存的架构理由:ThinkPHP 管后台,Laravel 管巡检服务

很多读者看到标题第一反应是:一个系统为什么要用两个PHP框架?这不是给自己找麻烦吗?说实话,如果在完全绿地项目里,我也不会开这个头。但这个项目有非常现实的历史背景——**公司原有的进销存系统是用 ThinkPHP 5.1 开发的,里面沉淀了完整的药品档案、批次入库、采购退货、销售出库等核心数据,不可能推倒重来。而这次新增的巡检业务模块,由一支更熟悉 Laravel 的开发小组来负责。**两块系统需要无缝协作,最终就形成了双框架共存的局面。

架构划分方式如下:

系统端使用框架核心职责
后台管理端(PC Web)ThinkPHP 5.1药品档案、批次库存管理、用户权限、基础数据维护、报表查询
巡检服务端(API)Laravel 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='巡检任务表';

明细表的核心是记录“某一批药品在某次巡检中的结果”。字段包含task_idbatch_idbatch_noposition_codeinspection_result(1-正常,2-异常)、exception_typeexception_descimage_urlsinspector_idinspected_at等。每条明细就是检查动作的留痕记录,之后追溯某批药品在某天的巡检状况,直接按batch_no + inspected_at索引查询即可。

3.2 温湿度记录和异常闭环的表结构

药品仓储的另一条生命线是温湿度。常温库要求 10~30 摄氏度,阴凉库不超过 20 摄氏度,冷库则要求在 2~8 摄氏度。每一次巡检都要同步记录温湿度数据,我单独建了storage_environment_log表,包含warehouse_codetemperaturehumidityrecord_type(1-自动采集,2-人工录入)、recorded_atrecorder_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是低端安卓设备,装不了太多APP,H5方案部署简单、跨设备兼容性好。

扫码时的核心逻辑是:扫描商品条形码 → 根据条码解析出药品编码 → 再按当前任务下的明细匹配批次 → 回显该批次的效期、数量、货位信息 → 巡检员确认后选择“正常”或“异常”。

商品条码里面通常只包含药品编码,不包含批次信息,所以扫码后还要让巡检员选择具体批号。这一步特别容易出操作错误,我的处理方式是:自动将“同编码 + 近效期”的批次排在列表前面并默认高亮,巡检员只需确认即可,将误选概率降到最低。

在冷库区域信号不好,所以 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签发 Token,ThinkPHP 老系统的接口要校验这个 Token。两边不能直接共用一套 JWT 类库,但签名算法是一致的,所以我的做法是:在 ThinkPHP 公共函数里写了一个verifyJwt()方法,用firebase/php-jwt来解码校验。关键在于.env里两边配置的JWT_SECRET必须完全一致,且JWT_ALGO都设为HS256

如果某一天你发现老系统接口偶尔能调通、偶尔 401,大概率是两边系统时间不同步导致nbf(not before)或exp(expiration)校验失败。我排查过这个问题,最后是在老系统的定时任务里加了一个校时脚本,才彻底解决。

5.2 跨库事务没法用

巡检服务在建库时,我特意把表建在了独立数据库inspection_db,没有跟老系统的erp_db混在一起。这带来一个麻烦:一个完整的业务操作可能要同时更新两个库,但数据库层面没法做跨库事务。

举个例子:巡检员在 PDA 上确认“某批次下架”,前提是库存数量必须足够。这涉及两步:Laravel 库写入异常记录,ThinkPHP 库扣减可用库存。如果第一步成功、第二步失败了,数据就处于不一致状态。

我的解决方案是引入本地消息表 + 定时补偿任务:

  • 在 Laravel 库建一张outbox_message表,记录待同步的业务消息(比如“批次下架待扣减”)。
  • 先把业务操作和消息写入放在同一个数据库事务里(保证本地一致性)。
  • 然后由 Laravel 的schedule定时任务读取未发送的消息,调用 ThinkPHP 库存接口。
  • ThinkPHP 接口处理成功后在消息表标记完成,失败则进入重试队列,并触发告警。

这种方式其实是一个轻量版的“本地消息表”模式,它是我能想到的成本最低且可靠的跨库一致性方案,生产环境跑了几个月没有出过数据不一致的问题。

5.3 时区设置不一致

这是一个极小但影响极大的细节。ThinkPHP 老系统在配置里没有设置时区,默认是PRC(东八区);Laravel 的app.phptimezone也设置为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 的功劳,而是需求理得清楚、边界划得明白、细节抠得足够狠。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 4:01:20

Ant Design按钮点击文字位移问题:CSS根因分析与实用修复方案

做管理后台的开发者,十有八九被一个细节折磨过:Ant Design 的按钮样式写得再规整,鼠标按下的那一瞬间,按钮里的文字还是会像被什么东西推了一下,轻微地往右下角或上方挪动几个像素,松开手指又弹回来。反复试…

作者头像 李华
网站建设 2026/9/18 4:00:00

开源AI代码审查工具open-code-review:原理、部署与90天实战经验

说个最近挺有感触的场景。我这边有个中等规模的团队,代码库不算大,但每周积压的待审查 PR 从来不会少于十个。我自己的习惯是下班前集中处理一轮,结果经常变成这样:打开一个 PR,改了三个文件,提交信息写得不…

作者头像 李华
网站建设 2026/9/18 3:59:49

技术博客创作规范:为何拒绝虚构项目‘YuE’

我无法根据当前输入生成符合要求的博文。原因如下:项目标题为“YuE”,但未提供任何实质性内容:项目正文为空、关键词为空、摘要描述为空;所谓“相关热搜词”和“最新网络热词”列表虽长,但属于泛化搜索流量词&#xff…

作者头像 李华
网站建设 2026/9/18 3:58:50

电动汽车充电站选址定容:蒙特卡罗、Voronoi图与排队论实战解析

简介:一份关于电动汽车充电站规划研究的学术PDF,适合电动汽车产业研究人员、电网规划工程师及相关专业师生查阅。资源为单文件PDF,压缩包大小约4.36MB。内容系统梳理了充电站选址定容的优化方法:以社会总成本最小为目标建模&#…

作者头像 李华