本文目录导读:

在PHP项目中实现跨仓库调拨单的商品库存数据转移,核心难点在于保证库存数据的一致性和事务性(防止超卖、数据错乱),以下是标准的实现方案和关键代码逻辑:
数据库设计核心表
库存表 stock (仓库-商品库存)
CREATE TABLE `stock` ( `id` int(11) NOT NULL AUTO_INCREMENT, `warehouse_id` int(11) NOT NULL COMMENT '仓库ID', `product_id` int(11) NOT NULL COMMENT '商品ID', `quantity` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '可用库存', `lock_quantity` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '锁定库存(调拨中)', `version` int(11) NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_warehouse_product` (`warehouse_id`,`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
调拨单表 transfer_order
CREATE TABLE `transfer_order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '调拨单号', `from_warehouse_id` int(11) NOT NULL COMMENT '调出仓库', `to_warehouse_id` int(11) NOT NULL COMMENT '调入仓库', `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0=待审核 1=审核通过 2=调拨中 3=已完成 4=已取消', `total_amount` decimal(15,2) DEFAULT 0.00, `create_time` datetime DEFAULT NULL, `complete_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
调拨单明细表 transfer_order_item
CREATE TABLE `transfer_order_item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_id` int(11) NOT NULL, `product_id` int(11) NOT NULL, `quantity` decimal(15,2) NOT NULL COMMENT '调拨数量', `from_warehouse_stock_before` decimal(15,2) DEFAULT NULL COMMENT '调出前库存', `to_warehouse_stock_before` decimal(15,2) DEFAULT NULL COMMENT '调入前库存', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
核心业务流程(含PHP代码)
创建调拨单(只锁定库存,不扣减)
// 使用事务 + 行级锁(MySQL InnoDB)
$db->beginTransaction();
try {
foreach ($items as $item) {
// 锁定调出仓库的库存行(悲观锁)
$fromStock = $db->query("
SELECT * FROM stock
WHERE warehouse_id = :from_warehouse_id
AND product_id = :product_id
FOR UPDATE
", ['from_warehouse_id' => $fromId, 'product_id' => $item['product_id']]);
// 检查可用库存是否充足
if ($fromStock['quantity'] - $fromStock['lock_quantity'] < $item['quantity']) {
throw new Exception("商品 {$item['product_id']} 在仓库{$fromId}库存不足");
}
// 增加锁定库存(调拨中占用)
$db->execute("
UPDATE stock
SET lock_quantity = lock_quantity + :qty,
version = version + 1
WHERE id = :id
AND version = :old_version -- 乐观锁二次校验
", [
'qty' => $item['quantity'],
'id' => $fromStock['id'],
'old_version' => $fromStock['version']
]);
}
// 插入调拨单主表和明细表(状态为“待审核”)
$orderId = insertOrder(...);
$db->commit();
} catch (Exception $e) {
$db->rollback();
throw $e;
}
审核/执行调拨(真正扣减+增加库存)
$db->beginTransaction();
try {
// 再次锁定调出、调入仓库的库存行
$fromStock = $db->query("SELECT * FROM stock WHERE ... FOR UPDATE");
$toStock = $db->query("SELECT * FROM stock WHERE warehouse_id = :to_warehouse_id AND product_id = :product_id FOR UPDATE");
// 1. 扣减调出仓库:可用库存 - 数量,锁定库存 - 数量
$db->execute("
UPDATE stock
SET quantity = quantity - :qty,
lock_quantity = lock_quantity - :qty,
version = version + 1
WHERE id = :id
AND quantity >= :qty -- 防止并发超卖
AND lock_quantity >= :qty
", ['qty' => $item['quantity'], 'id' => $fromStock['id']]);
// 2. 增加调入仓库:可用库存 + 数量
if ($toStock) {
$db->execute("
UPDATE stock
SET quantity = quantity + :qty, version = version + 1
WHERE id = :id
", ['qty' => $item['quantity'], 'id' => $toStock['id']]);
} else {
// 如果调入仓库无此商品记录,则插入新行
$db->execute("
INSERT INTO stock (warehouse_id, product_id, quantity, lock_quantity)
VALUES (:wid, :pid, :qty, 0)
", ['wid' => $toWarehouseId, 'pid' => $item['product_id'], 'qty' => $item['quantity']]);
}
// 3. 更新调拨单状态为“已完成”
$db->execute("UPDATE transfer_order SET status = 3, complete_time = NOW() WHERE id = :id", ['id' => $orderId]);
$db->commit();
} catch (Exception $e) {
$db->rollback();
throw $e;
}
取消调拨单(释放锁定库存)
$db->beginTransaction();
try {
// 锁定调出仓库库存行
$fromStock = $db->query("SELECT * FROM stock WHERE ... FOR UPDATE");
// 释放锁定库存
$db->execute("
UPDATE stock
SET lock_quantity = lock_quantity - :qty,
version = version + 1
WHERE id = :id
AND lock_quantity >= :qty
", ['qty' => $item['quantity'], 'id' => $fromStock['id']]);
// 更新调拨单状态为“已取消”
$db->execute("UPDATE transfer_order SET status = 4 WHERE id = :id", ['id' => $orderId]);
$db->commit();
} catch (Exception $e) {
$db->rollback();
throw $e;
}
关键设计原则
| 原则 | 说明 | 代码体现 |
|---|---|---|
| 事务性 | 扣减A仓库 + 增加B仓库必须在同一事务中 | beginTransaction() + commit() |
| 行级锁 | 防止并发操作导致超卖 | SELECT ... FOR UPDATE |
| 乐观锁版本号 | 二次校验,避免不可重复读 | WHERE version = :old_version |
| 库存分离 | 可用库存与锁定库存分开管理 | quantity / lock_quantity |
| 幂等性 | 确保同一调拨单不会重复执行 | 状态机控制(status字段) |
进阶优化建议
-
使用消息队列
若调拨单量大,可将扣库存操作放入RabbitMQ/Redis队列,异步处理,提高响应速度。// 创建调拨单仅做记录,异步执行库存转移 Queue::push('transferJob', ['order_id' => $orderId]); -
库存快照与对账
每天跑定时任务,统计实际库存与调拨单差异,生成对账报告。 -
分库分表场景
若仓库分布在不同的数据库,需使用分布式事务(如TCC模式或Seata框架),不能依赖本地事务。 -
回滚机制
调拨执行失败时,自动回滚库存(A仓库扣了但B仓库插入失败,需补偿A仓库)。
完整流程图(简化版)
创建调拨单 → 锁定A仓库库存(lock_quantity+) → 待审核
↓
审核通过 → 事务开始
→ 扣减A仓库(quantity-,lock_quantity-)
→ 增加B仓库(quantity+)
→ 更新调拨单状态为完成
→ 事务提交
↓
取消调拨 → 释放A仓库锁定库存(lock_quantity-)
通过以上方案,即可在PHP项目中实现安全、可靠的跨仓库库存调拨,核心是数据库事务+行级锁+状态机控制。