fix(lastleg): account return carton outbound stock atomically
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||
|---|---|---|---:|
|
||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 135 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 136 |
|
||||
|
||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
|
||||
|
||||
@@ -2258,6 +2258,34 @@ CREATE TABLE ll_relabel_status_history (
|
||||
KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标状态变更历史';
|
||||
|
||||
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
|
||||
CREATE TABLE ll_return_inventory_outbound_movement (
|
||||
id BIGINT NOT NULL COMMENT '退货出库SKU流水ID',
|
||||
outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID',
|
||||
carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID',
|
||||
carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码',
|
||||
outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照',
|
||||
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
|
||||
quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正',
|
||||
total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量',
|
||||
total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量',
|
||||
available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量',
|
||||
available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量',
|
||||
cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数',
|
||||
cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code),
|
||||
UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
|
||||
KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at),
|
||||
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
|
||||
AND total_before-total_after=quantity AND available_before-available_after=quantity
|
||||
AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after)
|
||||
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
|
||||
|
||||
CREATE TABLE ll_return_inventory_balance (
|
||||
id bigint NOT NULL COMMENT '退货库存余额ID',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
|
||||
@@ -2992,4 +3020,4 @@ CREATE TABLE event_delivery_attempt (
|
||||
KEY idx_delivery_attempt_outcome (outcome,started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 135 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 136 AS initialized_tables;
|
||||
|
||||
+5
-3
@@ -109,6 +109,7 @@ public class DeliveryTransferService {
|
||||
@Resource private DropshipCartonSkuMapper dropshipCartonSkus;
|
||||
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock;
|
||||
@Resource private InboundPlanPalletAllocationMapper planAllocations;
|
||||
@Resource private InboundPlanPalletMapper inboundPlanPallets;
|
||||
@Resource private WarehouseResourceGateway warehouseResources;
|
||||
@@ -672,9 +673,10 @@ public class DeliveryTransferService {
|
||||
ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks());
|
||||
requireReturnSource(outbound, item, carton);
|
||||
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks());
|
||||
carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now());
|
||||
if (returnCartons.updateById(carton) != 1) throw conflict("退货箱唛状态已变化,请重新核对");
|
||||
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
|
||||
long outboundPickId = IdWorker.getId();
|
||||
returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(),
|
||||
outbound.getOutboundNo(), outboundPickId);
|
||||
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(outboundPickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
|
||||
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
|
||||
updateOutboundPickingStatus(customerId, outbound);
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.returnreceipt.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Immutable SKU stock deltas written in the outbound pick transaction. */
|
||||
@Data
|
||||
@TableName("ll_return_inventory_outbound_movement")
|
||||
public class ReturnInventoryOutboundMovement {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long outboundPickId;
|
||||
private String customerId;
|
||||
private String overseasWarehouseId;
|
||||
private Long cartonId;
|
||||
private String cartonMark;
|
||||
private String outboundNo;
|
||||
private String skuCode;
|
||||
private String productNameZh;
|
||||
private Long quantity;
|
||||
private Long totalBefore;
|
||||
private Long totalAfter;
|
||||
private Long availableBefore;
|
||||
private Long availableAfter;
|
||||
private Long cartonsBefore;
|
||||
private Long cartonsAfter;
|
||||
private LocalDateTime occurredAt;
|
||||
}
|
||||
+12
@@ -12,6 +12,18 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventoryBalance> {
|
||||
@Select("SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
|
||||
"AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} FOR UPDATE")
|
||||
ReturnInventoryBalance lockSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
|
||||
@Param("skuCode") String skuCode);
|
||||
|
||||
@Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " +
|
||||
"available_quantity=available_quantity-#{quantity},carton_quantity=carton_quantity-1,updated_at=NOW(3) " +
|
||||
"WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1")
|
||||
int deductOutbound(@Param("id") long id, @Param("customerId") String customerId,
|
||||
@Param("warehouseId") String warehouseId, @Param("quantity") long quantity);
|
||||
|
||||
@Insert("INSERT INTO ll_return_inventory_balance (id,customer_id,overseas_warehouse_id,sku_code,product_name_zh,total_quantity,available_quantity,carton_quantity,created_at,updated_at) " +
|
||||
"VALUES (#{id},#{customerId},#{warehouseId},#{skuCode},#{productNameZh},#{totalQuantity},#{availableQuantity},#{cartons},NOW(3),NOW(3)) " +
|
||||
"ON DUPLICATE KEY UPDATE total_quantity=total_quantity+VALUES(total_quantity),available_quantity=available_quantity+VALUES(available_quantity),carton_quantity=carton_quantity+VALUES(carton_quantity), " +
|
||||
|
||||
+5
-3
@@ -7,15 +7,17 @@ import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Read-only return inventory movement ledger reconstructed from signed receipts and merged cartons. */
|
||||
/** Receipt/merge projections plus immutable outbound SKU movements. */
|
||||
@Mapper
|
||||
public interface ReturnInventoryMovementMapper {
|
||||
public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus.core.mapper.BaseMapper<com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement> {
|
||||
String MOVEMENTS = "SELECT o.customer_id customerId,o.overseas_warehouse_id overseasWarehouseId,ri.sku_code skuCode,oi.product_name_zh productNameZh," +
|
||||
"'RECEIPT' movementType,r.receipt_no referenceNo,ri.received_quantity totalDelta,ri.received_quantity availableDelta,r.signed_at occurredAt " +
|
||||
"FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id JOIN ll_return_receipt_item ri ON ri.receipt_id=r.id " +
|
||||
"JOIN ll_return_order_item oi ON oi.id=ri.return_order_item_id " +
|
||||
"UNION ALL SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,i.product_name_zh,'MERGE',c.carton_mark,0,-i.quantity,c.created_at " +
|
||||
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'";
|
||||
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'" +
|
||||
" UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'OUTBOUND',outbound_no," +
|
||||
"-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement";
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM (" + MOVEMENTS + ") m WHERE 1=1 " +
|
||||
"<if test='customerId != null and customerId != \"\"'>AND m.customerId=#{customerId} </if>" +
|
||||
|
||||
+53
@@ -60,6 +60,59 @@ public class ReturnInventoryService {
|
||||
@Resource private LastlegOrderDetailMapper lastlegOrderDetails;
|
||||
@Resource private com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway physicalPallets;
|
||||
|
||||
/** Called inside the PDA pick transaction; carton contents remain the physical shipment snapshot. */
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void shipCarton(String customerId, String warehouseId, String returnNo, String cartonMark,
|
||||
String outboundNo, long outboundPickId) {
|
||||
ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "退货箱码不能为空"));
|
||||
if (carton == null || !customerId.equals(carton.getCustomerId())
|
||||
|| !warehouseId.equals(carton.getOverseasWarehouseId()) || !returnNo.equals(carton.getSourceReturnNo())
|
||||
|| !AVAILABLE.equals(carton.getStatus())) throw conflict("退货箱状态或客户、仓库、来源不匹配");
|
||||
List<ReturnInventoryCartonItem> contents = cartonItems.lockCarton(carton.getId());
|
||||
long total = 0;
|
||||
var skus = new java.util.HashSet<String>();
|
||||
for (ReturnInventoryCartonItem content : contents) {
|
||||
if (!customerId.equals(content.getCustomerId()) || !carton.getId().equals(content.getCartonId())
|
||||
|| text(content.getSkuCode()) == null || !skus.add(content.getSkuCode()) || value(content.getQuantity()) <= 0) {
|
||||
throw conflict("退货箱内容缺失或归属、SKU数量不一致");
|
||||
}
|
||||
total = Math.addExact(total, content.getQuantity());
|
||||
}
|
||||
if (contents.isEmpty() || total != value(carton.getTotalQuantity())
|
||||
|| carton.getSkuTypes() == null || contents.size() != carton.getSkuTypes()) throw conflict("退货箱内容与实物数量不一致");
|
||||
contents = contents.stream().sorted(Comparator.comparing(ReturnInventoryCartonItem::getSkuCode)).toList();
|
||||
String scopedCustomer = carton.getCustomerId(), scopedWarehouse = carton.getOverseasWarehouseId();
|
||||
Map<String, com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance> lockedBalances = new LinkedHashMap<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (ReturnInventoryCartonItem content : contents) {
|
||||
var balance = balances.lockSku(scopedCustomer, scopedWarehouse, content.getSkuCode());
|
||||
long quantity = content.getQuantity();
|
||||
if (balance == null || value(balance.getTotalQuantity()) < quantity
|
||||
|| value(balance.getAvailableQuantity()) < quantity || value(balance.getCartonQuantity()) < 1) {
|
||||
throw conflict("退货库存不足,不能完成该箱出库:" + content.getSkuCode());
|
||||
}
|
||||
lockedBalances.put(content.getSkuCode(), balance);
|
||||
}
|
||||
for (ReturnInventoryCartonItem content : contents) {
|
||||
var balance = lockedBalances.get(content.getSkuCode());
|
||||
long quantity = content.getQuantity();
|
||||
var movement = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement();
|
||||
movement.setId(IdWorker.getId()); movement.setOutboundPickId(outboundPickId);
|
||||
movement.setCustomerId(scopedCustomer); movement.setOverseasWarehouseId(scopedWarehouse);
|
||||
movement.setCartonId(carton.getId()); movement.setCartonMark(carton.getCartonMark());
|
||||
movement.setOutboundNo(required(outboundNo, "出库单号不能为空"));
|
||||
movement.setSkuCode(content.getSkuCode()); movement.setProductNameZh(content.getProductNameZh()); movement.setQuantity(quantity);
|
||||
movement.setTotalBefore(balance.getTotalQuantity()); movement.setTotalAfter(balance.getTotalQuantity() - quantity);
|
||||
movement.setAvailableBefore(balance.getAvailableQuantity()); movement.setAvailableAfter(balance.getAvailableQuantity() - quantity);
|
||||
movement.setCartonsBefore(balance.getCartonQuantity()); movement.setCartonsAfter(balance.getCartonQuantity() - 1);
|
||||
movement.setOccurredAt(now);
|
||||
if (balances.deductOutbound(balance.getId(), scopedCustomer, scopedWarehouse, quantity) != 1) throw conflict("退货库存余额已变化,请重新核对");
|
||||
if (movements.insert(movement) != 1) throw new IllegalStateException("退货出库流水写入失败");
|
||||
}
|
||||
carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(now);
|
||||
if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
|
||||
String requestedMark, List<ReceiptLine> lines) {
|
||||
|
||||
@@ -2487,6 +2487,34 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
|
||||
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
|
||||
id BIGINT NOT NULL COMMENT '退货出库SKU流水ID',
|
||||
outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID',
|
||||
carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID',
|
||||
carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码',
|
||||
outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照',
|
||||
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
|
||||
quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正',
|
||||
total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量',
|
||||
total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量',
|
||||
available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量',
|
||||
available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量',
|
||||
cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数',
|
||||
cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code),
|
||||
UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code),
|
||||
KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at),
|
||||
CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0
|
||||
AND total_before-total_after=quantity AND available_before-available_after=quantity
|
||||
AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after)
|
||||
) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
|
||||
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0)
|
||||
|
||||
+120
-3
@@ -6,7 +6,11 @@ import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.*;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -33,13 +37,16 @@ import static org.mockito.Mockito.*;
|
||||
class ReturnOutboundPdaPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private DeliveryTransferService service;
|
||||
private ReturnInventoryMovementMapper movementMapper;
|
||||
private ReturnInventoryBalanceMapper balanceMapper;
|
||||
|
||||
@BeforeEach void setUp() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:return-pda-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
// Persist production entities through production MyBatis mappers, with real transactions.
|
||||
for (Class<?> entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class,
|
||||
OutboundPdaPick.class, ReturnInventoryCarton.class)) {
|
||||
OutboundPdaPick.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
|
||||
ReturnInventoryOutboundMovement.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) {
|
||||
String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers()))
|
||||
.map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
|
||||
+ (field.getType() == String.class ? "VARCHAR(2000)" : field.getType() == LocalDateTime.class ? "TIMESTAMP"
|
||||
@@ -49,15 +56,26 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
}
|
||||
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
|
||||
for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class,
|
||||
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class)) configuration.addMapper(mapper);
|
||||
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class,
|
||||
ReturnInventoryCartonItemMapper.class, ReturnInventoryBalanceMapper.class, ReturnInventoryMovementMapper.class)) configuration.addMapper(mapper);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
movementMapper = session.getMapper(ReturnInventoryMovementMapper.class);
|
||||
balanceMapper = session.getMapper(ReturnInventoryBalanceMapper.class);
|
||||
var target = new DeliveryTransferService();
|
||||
ReflectionTestUtils.setField(target, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
|
||||
ReflectionTestUtils.setField(target, "outboundPallets", session.getMapper(OutboundPalletItemMapper.class));
|
||||
ReflectionTestUtils.setField(target, "pdaTasks", session.getMapper(OutboundPdaTaskMapper.class));
|
||||
ReflectionTestUtils.setField(target, "pdaPicks", session.getMapper(OutboundPdaPickMapper.class));
|
||||
ReflectionTestUtils.setField(target, "returnCartons", session.getMapper(ReturnInventoryCartonMapper.class));
|
||||
var stock = new ReturnInventoryService();
|
||||
ReflectionTestUtils.setField(stock, "cartons", session.getMapper(ReturnInventoryCartonMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "cartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "balances", session.getMapper(ReturnInventoryBalanceMapper.class));
|
||||
ReflectionTestUtils.setField(stock, "movements", session.getMapper(ReturnInventoryMovementMapper.class));
|
||||
var stockProxy = new ProxyFactory(stock); stockProxy.setProxyTargetClass(true);
|
||||
stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy());
|
||||
var numbers = mock(LastlegOrderNoGenerator.class);
|
||||
when(numbers.next(eq("RETURN_OUTBOUND"), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2));
|
||||
ReflectionTestUtils.setField(target, "orderNoGenerator", numbers);
|
||||
@@ -69,6 +87,12 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
}
|
||||
private void carton(long id, String customer, String warehouse, String returnNo, String mark) {
|
||||
jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,source_return_no,carton_mark,status,total_quantity) VALUES(?,?,?,?,?,'AVAILABLE',5)", id, customer, warehouse, returnNo, mark);
|
||||
jdbc.update("UPDATE ll_return_inventory_carton SET sku_types=1 WHERE id=?", id);
|
||||
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity) VALUES(?,?,?,'SKU-A',5)", id, id, customer);
|
||||
if (jdbc.update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity+5,available_quantity=available_quantity+5,carton_quantity=carton_quantity+1 WHERE customer_id=? AND overseas_warehouse_id=? AND sku_code='SKU-A'", customer, warehouse) == 0) {
|
||||
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(?,?,?,'SKU-A',5,5,1)", id, customer, warehouse);
|
||||
}
|
||||
|
||||
}
|
||||
private com.baiyue.lastleg.delivery.dto.DeliveryResponses.PdaTask create(String key, String... marks) {
|
||||
return service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1", List.of(marks), "fixture", key));
|
||||
@@ -150,6 +174,7 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
var task = create("rollback", "BOX-A");
|
||||
var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "rollback-pick", List.of("BOX-A"));
|
||||
var stockBefore = stockSnapshot();
|
||||
jdbc.execute("ALTER TABLE ll_outbound_pda_pick ADD CONSTRAINT fail_pick CHECK(idempotency_key <> 'rollback-pick')");
|
||||
assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), request));
|
||||
assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
|
||||
@@ -157,9 +182,101 @@ class ReturnOutboundPdaPersistenceTest {
|
||||
var unchanged = service.pdaTaskDetail("WH-1", task.taskNo());
|
||||
assertEquals("PENDING", unchanged.task().status());
|
||||
assertEquals(1, unchanged.items().get(0).positions().get(0).availablePackages());
|
||||
assertEquals(stockBefore, stockSnapshot());
|
||||
jdbc.execute("ALTER TABLE ll_outbound_pda_pick DROP CONSTRAINT fail_pick");
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), request);
|
||||
assertEquals("OUTBOUNDED", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class));
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
|
||||
}
|
||||
private java.util.Map<String, Object> stockSnapshot() {
|
||||
var result = new java.util.LinkedHashMap<String, Object>();
|
||||
for (String table : List.of("ll_return_inventory_balance", "ll_return_inventory_carton", "ll_return_inventory_carton_item",
|
||||
"ll_return_inventory_outbound_movement", "ll_outbound_pda_pick", "ll_outbound_pallet_item", "ll_outbound_pda_task", "ll_outbound_order")) {
|
||||
result.put(table, jdbc.queryForList("SELECT * FROM " + table + " ORDER BY id"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Test void outboundDeductsSkuQuantityNotPackageCountAndReplayDoesNotDeductAgain() {
|
||||
var task = create("stock", "BOX-A");
|
||||
var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "stock-pick", List.of("BOX-A"));
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT total_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT carton_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT quantity FROM ll_return_inventory_carton_item WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT total_quantity FROM ll_return_inventory_carton WHERE id=1", Long.class));
|
||||
assertEquals(5L, jdbc.queryForObject("SELECT quantity FROM ll_return_inventory_outbound_movement", Long.class));
|
||||
var ledger = movementMapper.platformPage("C1", "WH-1", "SKU-A", 0, 20);
|
||||
assertEquals(1, ledger.size()); assertEquals("OUTBOUND", ledger.get(0).movementType());
|
||||
assertEquals(task.outboundNo(), ledger.get(0).referenceNo());
|
||||
assertEquals(-5L, ledger.get(0).totalDelta()); assertEquals(-5L, ledger.get(0).availableDelta());
|
||||
assertEquals(0, movementMapper.platformCount("C2", "WH-1", "SKU-A"));
|
||||
assertEquals(0, movementMapper.platformCount("C1", "WH-2", "SKU-A"));
|
||||
var after = stockSnapshot(); service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(after, stockSnapshot());
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
|
||||
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "another-key", List.of("BOX-A"))));
|
||||
assertEquals(after, stockSnapshot());
|
||||
}
|
||||
|
||||
@Test void insufficientStockWrongCodeAndSourceDriftAreZeroWriteFailures() {
|
||||
var task = create("insufficient", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "try", List.of("BOX-A"));
|
||||
for (String field : List.of("total_quantity", "available_quantity", "carton_quantity")) {
|
||||
long original = jdbc.queryForObject("SELECT " + field + " FROM ll_return_inventory_balance WHERE id=1", Long.class);
|
||||
jdbc.update("UPDATE ll_return_inventory_balance SET " + field + "=0 WHERE id=1");
|
||||
var before = stockSnapshot();
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.update("UPDATE ll_return_inventory_balance SET " + field + "=? WHERE id=1", original);
|
||||
}
|
||||
var before = stockSnapshot();
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(),
|
||||
new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "wrong", List.of("BOX-B"))));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.update("UPDATE ll_return_inventory_carton SET source_return_no='FOREIGN' WHERE id=1"); before = stockSnapshot();
|
||||
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
}
|
||||
|
||||
@Test void outboundSkuJournalFailureRollsBackAllStockAndTaskFacts() {
|
||||
var task = create("journal", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "journal-pick", List.of("BOX-A"));
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT fail_sku_journal CHECK(quantity < 0)");
|
||||
var before = stockSnapshot();
|
||||
assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement DROP CONSTRAINT fail_sku_journal");
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_outbound_movement", Integer.class));
|
||||
}
|
||||
|
||||
@Test void secondSkuJournalFailureRollsBackTheAlreadyDeductedFirstSku() {
|
||||
jdbc.update("UPDATE ll_return_inventory_carton SET total_quantity=7,sku_types=2 WHERE id=1");
|
||||
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity) VALUES(10,1,'C1','SKU-B',2)");
|
||||
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(10,'C1','WH-1','SKU-B',2,2,1)");
|
||||
var task = create("multi-sku", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0);
|
||||
var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "multi-sku-pick", List.of("BOX-A"));
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT fail_second_sku CHECK(sku_code <> 'SKU-B')");
|
||||
var before = stockSnapshot();
|
||||
assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), pick));
|
||||
assertEquals(before, stockSnapshot());
|
||||
jdbc.execute("ALTER TABLE ll_return_inventory_outbound_movement DROP CONSTRAINT fail_second_sku");
|
||||
service.pdaPick("WH-1", "operator", task.taskNo(), pick);
|
||||
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_outbound_movement", Integer.class));
|
||||
assertEquals(7L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class));
|
||||
assertEquals(0L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class));
|
||||
assertEquals(0L, jdbc.queryForObject("SELECT carton_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class));
|
||||
assertEquals(7L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_carton_item WHERE carton_id=1", Long.class));
|
||||
}
|
||||
|
||||
@Test void balanceUpdateCannotCrossCustomerOrWarehouseEvenWithAnExistingRowId() {
|
||||
var before = stockSnapshot();
|
||||
assertEquals(0, balanceMapper.deductOutbound(1, "C2", "WH-1", 1));
|
||||
assertEquals(0, balanceMapper.deductOutbound(1, "C1", "WH-2", 1));
|
||||
assertEquals(before, stockSnapshot());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
|
||||
const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse']
|
||||
|
||||
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 135]]) {
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 136]]) {
|
||||
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
|
||||
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
|
||||
const body = sql.replace(/^--.*$/gm, '')
|
||||
@@ -28,10 +28,19 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
|
||||
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
|
||||
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
|
||||
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
|
||||
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|135) AS initialized_tables;$/)
|
||||
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|136) AS initialized_tables;$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('退货出库不可变流水限制重复扣减并保存数量守恒快照', () => {
|
||||
const journal = table(overseas, 'baiyue_lastleg', 'll_return_inventory_outbound_movement')
|
||||
assert.match(journal, /UNIQUE KEY uk_ll_return_outbound_pick_sku\(outbound_pick_id,sku_code\)/i)
|
||||
assert.match(journal, /UNIQUE KEY uk_ll_return_outbound_carton_sku\(customer_id,carton_id,sku_code\)/i)
|
||||
assert.match(journal, /CHECK\(quantity>0 AND total_after>=0 AND available_after>=0/i)
|
||||
assert.match(journal, /total_before-total_after=quantity AND available_before-available_after=quantity/i)
|
||||
assert.match(journal, /cartons_before-cartons_after=1 AND total_after>=available_after/i)
|
||||
})
|
||||
|
||||
test('平台管理员由外部bcrypt引导,强制首登改密且不创建客户', () => {
|
||||
const accounts = seedRows(cn, 'auth_account')
|
||||
assert.equal(accounts.length, 1)
|
||||
|
||||
Reference in New Issue
Block a user