ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

双框架实战:ThinkPHP与Laravel下的企业物资调拨系统设计

双框架实战:ThinkPHP与Laravel下的企业物资调拨系统设计 从接手这个项目的第一天起我就知道它不会是个“排好数据库、写几个增删改查”就交差的活儿。企业物资调拨管理系统听起来只是把一个调拨申请做成电子流程但真正跑起来之后牵扯到审批链、库存实时扣减、多仓库协同、单据追溯连“一条调拨单删了以后明细怎么办”这种细节都能在不同框架里演出完全不一样的坑。这个项目更特殊的地方在于客户内部技术栈分裂一部分运维只碰过 ThinkPHP 6另一部分集团开发组要求后续统一在 Laravel 上做二次开发。最后我交付的不是一套系统而是两套框架实现——也就是项目标题里的“Thinkphp_Laravel框架”。项目代号 18df5j3u对应内网版本库的一个分支代码量不大但里面踩过的坑和沉淀下来的设计思路我觉得值得单独写一篇完整的复盘。我先把最有价值的结论放在前面物资调拨系统的核心不是“调拨单”这个表单而是每一次状态变更对库存产生的影响。谁能把“单据流”和“库存流”拧成一条可靠的事务链谁就成功了一多半。框架选 ThinkPHP 还是 Laravel反倒是次要问题。1. 双框架交付这套物资调拨系统为什么做成两套代码1.1 客户技术栈分裂逼出来的双版本方案项目初期客户内部开会时就把我整“分裂”了。老厂区的信息科说他们之前所有内部系统都是 ThinkPHP 写的phpStudy 搭环境、改改控制器就能跑团队里没人接触过 Laravel 那一套门门道道但集团层面又下了文件后续所有系统的底层要统一走 Laravel因为要接集团的统一登录、消息队列和定时任务平台。两头都不能得罪。刚开始我想过用 ThinkPHP 写主体再套一层 Laravel 的 API 网关把 Laravel 当成“外挂”来给集团对接数据。但一画数据流就发现不对调拨单的审批、出库、入库都是从后台界面操作的操作路径绕到 API 再转回来既不直观也容易出事务问题。而且业务以后还要扩展我不想让核心逻辑散在两个框架之间。最后我拍板同一个 MySQL 库同一套表结构同一份状态机定义外部包两套 Web 应用——一套在用 ThinkPHP 6一套用 Laravel 9。两套应用不混用代码界面和功能一致性完全对齐。这样老厂区继续维护 ThinkPHP集团开发组直接接手 Laravel谁也不挡谁的道。1.2 两个框架的分工边界与代码组织方式有人会觉得这就是“重复造轮子”。我的处理方法是把重复控制在最低限度数据库的表结构、枚举值、业务规则文档只维护一份框架层分别实现但控制器里只写“Http 层”的代码真正的业务逻辑丢到 Service 服务类里。这样说可能比较抽象我举个例子。物资调拨里有个非常核心的动作——审核通过后调出仓库的库存要预占或直接扣减。这个逻辑如果散写在控制器里ThinkPHP 版和 Laravel 版会越走越偏。我规定两套程序都必须有一个TransferStockService暴露同名的confirmOutbound()、confirmInbound()、rollback()方法。内部实现各写各的但行为必须一致。这样组织代码的好处很明显后续业务规则变了比如“审核通过后必须检查调出仓的可用库存是否充足”我只需要把修改后的规则同步到两个 Service 里而不会出现两个框架行为不一致的问题。项目运行了大半年事实证明这个约定价值很高至少客户问“为什么 Laravel 版和 ThinkPHP 版按钮行为不一样”这种事情一次都没发生过。2. 先把业务链路压缩成数据表调拨单、明细与库存的关系2.1 一条调拨单从草稿到归档的完整状态流转做任何管理系统我都习惯先画状态流转图不画清楚绝不动手写代码。物资调拨单的状态看起来复杂拆开其实就是一条流水线草稿填单人在选物资、调数量还没提交。待审批提交给调出部门的负责人。审批驳回负责人觉得数量不对、用途不明确退回填单人修改。待出库审批通过调出仓库开始拣货、核对实物。已出库调出仓确认货已发出这时调出仓库存正式扣减。在途物资已经出了调出仓还没有被调入库签收。已入库调入库确认签收调入仓库存增加整个调拨流程闭环。已归档财务或物资管理部门确认无差异单据锁死。这里有一个关键设计点调出库存的扣减时机不是“审批通过”而是“确认出库”。为什么因为审批通过到实际拣货之间可能隔几天如果审批通过就把库存扣了但实际上货没发出去就会造成仓库账面和实物不符。反过来如果确认出库才扣那审批过程中库存就可能被别人抢走。折中方案是引入“冻结库存”也叫锁定库存概念审批通过后先冻结可用库存确认出库时把冻结转为扣减。2.2 核心表结构与字段设计整个系统我拆成了六张核心业务表结构如下表所示表名主要字段作用warehouse_infoid, warehouse_name, warehouse_code, type仓库/部门基础信息material_infoid, material_code, material_name, spec, unit物资档案transfer_orderid, order_sn, out_warehouse_id, in_warehouse_id, apply_user_id, status, audit_user_id, audit_remark, created_at, updated_at调拨单主表transfer_order_itemid, order_id, material_id, quantity, remark调拨单明细stock_infoid, warehouse_id, material_id, locked_quantity, available_quantity, total_quantity仓库库存stock_movement_logid, order_id, warehouse_id, material_id, change_type, change_quantity, before_quantity, after_quantity, created_at库存变动流水库存表我特意把total_quantity拆成locked_quantity和available_quantity两个字段。total_quantity是账面物理库存available_quantity是能继续调拨/领用的库存locked_quantity是已经冻结但未出库的部分。这样做库存查询、锁定、回滚都会变得很直观。2.3 业务单号的生成不靠自增裸奔要有唯一兜底调拨单号必须可读、可追溯。我用的是“单号前缀 日期 仓库代码 四位流水号”比如DB20250612WH01-0001。这个单号我直接在应用层生成生成逻辑用了数据库唯一索引兜底因为高并发下如果只靠程序“查一下有没有重复再插入”很容易出问题。这里有一点容易被忽略如果两个仓库同时发起调拨流水号都是从 0001 开始单号就可能冲突。我在transfer_order.order_sn上建了唯一索引并在生成单号时把“仓库代码”作为区分维度。真出现极端的并发重复数据库会直接报唯一键冲突捕获异常后重新生成即可绝不会给后面查账埋雷。3. ThinkPHP 6 版本库存事务、审批回滚与关联删除实操3.1 创建调拨单主表和明细表一次性写入ThinkPHP 6 写物资调拨单做法比较直白。先校验参数然后在事务里创建主单再循环保存明细。我这里给一个简化但不失核心逻辑的示例public function createOrder(array $payload): TransferOrder { $orderSn $this-generateOrderSn($payload[out_warehouse_id]); Db::startTrans(); try { $order TransferOrder::create([ order_sn $orderSn, out_warehouse_id $payload[out_warehouse_id], in_warehouse_id $payload[in_warehouse_id], apply_user_id $payload[apply_user_id], status TransferOrder::STATUS_DRAFT, ]); $items []; foreach ($payload[items] as $item) { $items[] [ order_id $order-id, material_id $item[material_id], quantity $item[quantity], remark $item[remark] ?? , ]; } (new TransferOrderItem())-saveAll($items); Db::commit(); return $order; } catch (\Throwable $e) { Db::rollback(); throw $e; } }这个写法有两个细节我想特别说明。第一saveAll之前要把所有明细的数组统一构造好不是因为性能而是为了确保order_id在插入前就已经被赋值避免某些 TP 版本中关联写入顺序造成的外键空值。第二业务参数校验最好放在事务外不然一个非法参数就会拖垮整个事务日志里还不容易定位。3.2 出库/入库的库存扣减顺序和锁不能乱库存扣减是整个系统最需要谨慎的地方。我用的方式是行锁ThinkPHP 6 里直接用lock(true)。调出仓确认出库时先锁库存行再判断可用库存是否充足最后扣减Db::startTrans(); try { foreach ($items as $item) { $stock StockInfo::where(warehouse_id, $order[out_warehouse_id]) -where(material_id, $item[material_id]) -lock(true) -find(); if (!$stock || $stock[available_quantity] $item[quantity]) { throw new \Exception(物资 . $item[material_id] . 可用库存不足); } $stock-available_quantity - $item[quantity]; $stock-locked_quantity - $item[quantity]; $stock-total_quantity - $item[quantity]; $stock-save(); // 写库存变动流水方便追溯 StockMovementLog::create([...]); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; }我踩过的坑是对多条明细循环扣库存时如果调出仓有多条物资按不同顺序处理并发场景下会形成死锁。比如单 A 先锁物资 1 再锁物资 2单 B 先锁物资 2 再锁物资 1两边互相等MySQL 会杀掉其中一个事务导致系统报死锁异常。解决办法很简单——在循环之前把明细按material_id排序所有事务都按同一个顺序加锁死锁概率会大幅下降。这个细节很多文档不会写但真实项目里我是靠它救回一条命的。3.3 审批驳回与撤销库存如何正确回补审批驳回和撤销是两个容易被人忽略的库存操作。调拨单在审批通过时我已经把调出仓的库存做了“冻结”也就是available_quantity减少、locked_quantity增加。如果审批被驳回或者填单人主动撤销订单根本没实际出库就必须把冻结回补。回补逻辑我用一个独立的 Service 方法rollbackStock()封装绝不在控制器里写 SQL。这个方法做的事情正好和冻结相反available_quantity增加、locked_quantity减少同时写一条“回补”类型的流水。这里还有个状态限制要特别注意不是所有状态的单子都能回补。比如订单已经“已出库”说明实物已经动了这时候不能简单地做库存回补而要走“退货调拨”或“红冲单”流程。我在代码里加了严格的状态机判断只允许“待审批”“待出库”状态下的单据做回补其他状态一律抛业务异常。这样的设计一开始会让人觉得繁琐但仓库对账时会感激你多做这么一道防线。3.4 关键坑ThinkPHP 关联删除不会自动带出明细这个项目里我特意把“删除”场景设计得非常保守物理删除只允许在“草稿”状态下进行其他状态一律逻辑删除。但即使草稿状态删主单时也必须把明细一起删掉否则就会在 database 里留下孤儿数据。ThinkPHP 6 里TransferOrder::destroy($id)并不会自动删除关联的transfer_order_item。如果你用 TP 自带的hasMany关联默认删除主模型时不会级联删除子记录。网上很多文章让你在模型里写public static function onBeforeDelete($order) { TransferOrderItem::where(order_id, $order[id])-delete(); }这个思路是对的但我在实际项目中不直接用模型事件而是把删除逻辑同样收敛到 Service 里显式调用明细删除。原因很简单模型事件对三年后的维护者来说太“隐晦”了人在控制器里翻半天都找不到删除明细的代码。显式调用虽然不够帅但可读性极高。如果你非要用关联删除也好但一定要确认模型事件里的事务边界。TP6 的模型事件默认不会把主表删除和子表删除包在同一个事务里一旦子表删除失败主表已经被删了数据完整性当场出问题。我的建议是手写事务先删子表再删主表保证要么都成功要么都回滚。4. Laravel 9 版本同一套表结构下的工程化差异4.1 迁移与 Eloquent表结构不变模型层重构Laravel 版本最大的优势是工程化内置能力比较多。数据库表结构我没有重新设计直接沿用了 ThinkPHP 版本的表只是为 Laravel 这边建了独立的 migration 来做结构同步。迁移文件的好处是集团服务器上新环境部署时跑一遍php artisan migrate就能把表结构拉起不会出现“我拷了一份 SQL 文件结果执行了一半报错”的尴尬。模型层使用 Eloquent定义好TransferOrder和TransferOrderItem的关系class TransferOrder extends Model { protected $table transfer_order; public function items() { return $this-hasMany(TransferOrderItem::class, order_id, id); } }在做调拨单详情查询时直接TransferOrder::with(items)-find($id)就能把主单和明细一起取出来比 ThinkPHP 的with在写法上更顺手。但从项目整体角度看Eloquent 和 TP 模型只是“面子”不同“里子”——数据库表结构、字段含义、状态枚举——完全一致所以两边维护成本并没有翻倍。4.2 表单校验与模型观察者把业务约束放对位置Laravel 里我大量使用了 FormRequest 做参数校验。举个例子创建调拨单必须校验调出仓库和调入仓库不能是同一个这个约束如果在控制器里写容易被绕过放到 FormRequest 的withValidator里整个流程都会被强制覆盖public function rules(): array { return [ out_warehouse_id required|integer|exists:warehouse_info,id, in_warehouse_id required|integer|exists:warehouse_info,id|different:out_warehouse_id, items required|array|min:1, items.*.material_id required|integer|exists:material_info,id, items.*.quantity required|numeric|gt:0, ]; }调用仓库和调出仓库不能一样的校验用different:out_warehouse_id一行就表达清楚了不用在控制器里写 if。这个设计让 Laravel 版代码比 ThinkPHP 版更“声明式”可读性也高一些。至于观察者Observer我在 Laravel 版本中用在了“模型删除”场景。不过和 ThinkPHP 版一样我不依赖它做核心事务至少不在 Observer 里直接执行销毁逻辑。Observer 更适合用来写操作日志比如记录谁在什么时间把单子状态从“待审批”改成了“待出库”。4.3 DB事务与行锁跨框架保持一致的库存一致性老有人以为 Laravel 性能比 ThinkPHP 差其实在库存扣减这种场景下性能差异远没有代码质量差异影响大。Laravel 这边我用DB::transaction()包裹整个库存扣减流程查询库存时使用lockForUpdate()对应 ThinkPHP 里的lock(true)DB::transaction(function () use ($order, $items) { $items $items-sortBy(material_id); foreach ($items as $item) { $stock StockInfo::where(warehouse_id, $order[out_warehouse_id]) -where(material_id, $item[material_id]) -lockForUpdate() -first(); if (! $stock || $stock-available_quantity $item[quantity]) { throw new BusinessException(可用库存不足); } $stock-available_quantity - $item[quantity]; $stock-locked_quantity - $item[quantity]; $stock-total_quantity - $item[quantity]; $stock-save(); $this-writeMovementLog($order, $item, OUTBOUND); } });我特意在两套代码里都保留“按 material_id 排序后再加锁”的习惯保证并发场景下锁顺序一致。这是跨框架库存一致性的关键。MySQL 事务和行锁的行为不因框架改变真正改变系统命运的永远是写代码的人有没有尊重锁的顺序。另外Laravel 的队列我是真的用了给审批人生成待办通知。TP6 版本也装了 think-queue但 Laravel 的队列生态更成熟组件几乎不用调就能和 Redis 接起来。物资调拨单审批流比较轻通知晚几秒问题不大所以这里用队列最大的收益反而不是性能而是把“发通知”和“改状态”解耦状态更新失败时不能影响通知的可靠性。5. 部署配置里最容易翻车的三个点二级域名、运行目录与伪静态5.1 后台为什么要单独绑二级域名以及怎么绑企业物资调拨系统一般分用户端和管理端。用户端给各仓库、各部门员工填申请管理端给物资管理员、审批人看数据、做审核。如果我直接在一个域名下写/admin路由也不是不行但容易出现两个问题一是 Cookie 作用域把所有页面都带进了登录态不小心打开后台页面时还可能被浏览器预加载安全上不严谨二是以后要再接小程序或者对接集团门户后台和用户端共用域名会很麻烦。所以我坚持给后台单独绑一个二级域名比如admin.example.com。操作方式很常规DNS 解析加一条 A 记录指向服务器 IP然后在 Nginx 里单独写 server 块根目录仍然指向public。我在项目里用的是这样的伪静态配置server { listen 80; server_name admin.example.com; root /www/wwwroot/transfer/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?$s$uri$args; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }这段配置对 ThinkPHP 6 和 Laravel 都适用关键就是try_files把不存在的路径回退到index.php。如果这一步漏掉你会看到首页能开但点进任何调拨单详情页全部 404或者 ThinkPHP 报“路由不存在”。5.2 使用小皮控制面板部署 ThinkPHP运行目录必须指到 public很多中小型公司内部服务器都是 Windows 环境直接装 phpStudy也就是小皮面板来跑 PHP 项目。我在给客户老厂区部署 ThinkPHP 版本时就被“小皮控制面板怎么指定运行目录”这个事折腾了一番。小皮里创建站点时通常会让你填“域名”和“目录”。如果你把目录填成项目根目录比如D:\www\transfer\那么访问时别人可以直接在 URL 里带上/application路径去探测你的源码极不安全。正确做法是网站目录直接填D:\www\transfer\public也就是让 Web 服务器的根目录落在 public 下这样框架的核心文件全部在 Web 根之外外部访问不到。同时在小皮面板的“伪静态”配置里选择thinkphp对应的规则模板或者手动填写location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } }这一步不做ThinkPHP 路由会全部失效后台点了半天什么都打不开。之前有个同事在这上面卡了一下午最后发现就是没选伪静态规则其实几分钟就能搞定。5.3 二级域名下的登录状态与访问路径问题把后台绑到二级域名后还有两个隐蔽的坑。第一是应用的域名配置ThinkPHP 里如果用了 cookie 操作cookie(user_info)默认作用域是当前域名admin.example.com和www.example.com不互通。如果用户端和后台登录态需要隔离这反而是好事如果希望后台登录后用户端也能保持登录就必须把 cookie 的作用域配置到顶级域名.example.com。我在这个项目里选择的是彻底隔离也就是后台一套登录体系用户端另一套登录体系互不干扰安全边界更清晰。第二个坑是资源文件路径。后台首页如果写的是link relstylesheet href/css/app.css这个绝对路径会跟随访问域名变化本来没问题。但如果你之前用 IP 域名调试过后来绑了二级域名浏览器缓存里还留着 IP 域的静态资源地址必须清理缓存或者强制刷新。我在上线时统一走 CDN 或绝对协议路径杜绝了这种“本地看正常一上服务器就裸奔”的经典事故。6. 上线跑一段时间后我觉得这套系统最值钱的部分6.1 让仓库作业“只看到一个待办列表”系统上线初期仓库员工最反感的是“多了一步操作”。原来大家用 Excel 传递调拨信息想什么时候改就什么时候改现在被系统流程卡住多少都会有点抵触。我没跟员工讲大道理只做了一件事把首页改成“待办列表”你今天需要处理的出库单、入库单、驳回单全部列在上面点进去就是操作按钮不需要翻菜单、不需要记路由。这比任何培训都管用。流程类系统能不能推行下去很多时候不取决于功能多不多而取决于使用成本低不低。6.2 在途状态与库存冻结别等到对账才发现差异我们在仓库对账时遇到过一个问题调出仓已经出库了但调入仓因为各种原因没有及时入库系统账面库存就会和实物库存出现时间差。如果不做“在途”状态财务对账时一定炸锅。我后来在状态机里明确加了“在途”并在库存表中保留冻结字段确保调出仓和调入仓看到的库存口径是清晰的。我的体会是业务设计阶段多想一步“异常挂起”怎么办胜过上线后天天被对账问题追着跑。6.3 双框架项目维护到现在的一点体会这个项目最值钱的部分反而不是两套代码本身而是我在两套代码里都坚持的“Service 层同构”原则。ThinkPHP 和 Laravel 再怎么不同底层都是 PHP都能写清晰的 Service 类都能把状态机、事务边界、锁顺序这些核心问题处理好。框架是壳业务规则才是芯。做个项目如果能把“业务怎么变、库存怎么动”想透你用哪个框架都只是写接口的问题。最后再分享一个实用经验这种双框架系统的登录认证我建议不要强行互换。ThinkPHP 版用 sessionLaravel 版用 token两套逻辑各管各的。看似不统一实际反而好维护因为两边框架的中间件机制差异太大硬凑在一起才是给自己找麻烦。做管理系统永远要记得稳定、可用、能快速排查问题比“代码写得很帅”重要得多。
返回列表