双框架实战:ThinkPHP与Laravel下的企业物资调拨系统设计
2026/9/18 3:48:31 网站建设 项目流程

从接手这个项目的第一天起,我就知道它不会是个“排好数据库、写几个增删改查”就交差的活儿。企业物资调拨管理系统,听起来只是把一个调拨申请做成电子流程,但真正跑起来之后,牵扯到审批链、库存实时扣减、多仓库协同、单据追溯,连“一条调拨单删了以后明细怎么办”这种细节都能在不同框架里演出完全不一样的坑。这个项目更特殊的地方在于:客户内部技术栈分裂,一部分运维只碰过 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_quantityavailable_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,定义好TransferOrderTransferOrderItem的关系:

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.comwww.example.com不互通。如果用户端和后台登录态需要隔离,这反而是好事;如果希望后台登录后用户端也能保持登录,就必须把 cookie 的作用域配置到顶级域名.example.com。我在这个项目里选择的是彻底隔离,也就是后台一套登录体系,用户端另一套登录体系,互不干扰,安全边界更清晰。

第二个坑是资源文件路径。后台首页如果写的是<link rel="stylesheet" href="/css/app.css">,这个绝对路径会跟随访问域名变化,本来没问题。但如果你之前用 IP 域名调试过,后来绑了二级域名,浏览器缓存里还留着 IP 域的静态资源地址,必须清理缓存或者强制刷新。我在上线时统一走 CDN 或绝对协议路径,杜绝了这种“本地看正常,一上服务器就裸奔”的经典事故。

6. 上线跑一段时间后,我觉得这套系统最值钱的部分

6.1 让仓库作业“只看到一个待办列表”

系统上线初期,仓库员工最反感的是“多了一步操作”。原来大家用 Excel 传递调拨信息,想什么时候改就什么时候改,现在被系统流程卡住,多少都会有点抵触。我没跟员工讲大道理,只做了一件事:把首页改成“待办列表”,你今天需要处理的出库单、入库单、驳回单全部列在上面,点进去就是操作按钮,不需要翻菜单、不需要记路由。这比任何培训都管用。流程类系统能不能推行下去,很多时候不取决于功能多不多,而取决于使用成本低不低。

6.2 在途状态与库存冻结,别等到对账才发现差异

我们在仓库对账时遇到过一个问题:调出仓已经出库了,但调入仓因为各种原因没有及时入库,系统账面库存就会和实物库存出现时间差。如果不做“在途”状态,财务对账时一定炸锅。我后来在状态机里明确加了“在途”,并在库存表中保留冻结字段,确保调出仓和调入仓看到的库存口径是清晰的。我的体会是:业务设计阶段多想一步“异常挂起”怎么办,胜过上线后天天被对账问题追着跑。

6.3 双框架项目维护到现在的一点体会

这个项目最值钱的部分,反而不是两套代码本身,而是我在两套代码里都坚持的“Service 层同构”原则。ThinkPHP 和 Laravel 再怎么不同,底层都是 PHP,都能写清晰的 Service 类,都能把状态机、事务边界、锁顺序这些核心问题处理好。框架是壳,业务规则才是芯。做个项目如果能把“业务怎么变、库存怎么动”想透,你用哪个框架都只是写接口的问题。

最后再分享一个实用经验:这种双框架系统的登录认证,我建议不要强行互换。ThinkPHP 版用 session,Laravel 版用 token,两套逻辑各管各的。看似不统一,实际反而好维护,因为两边框架的中间件机制差异太大,硬凑在一起才是给自己找麻烦。做管理系统,永远要记得:稳定、可用、能快速排查问题,比“代码写得很帅”重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询