功能提交

This commit is contained in:
2026-08-19 09:24:56 +08:00
parent 31cf37c528
commit 8bf775f885
57 changed files with 6374 additions and 802 deletions
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -25,9 +25,25 @@
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` |
| 数据库 | `src/main/resources/db/lastleg.sql` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
## 数据库表索引与注释约束
| 业务域 | 表名 | 中文含义 |
|---|---|---|
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 |
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
| 采购与卡车 | `ll_overseas_procurement_*`、`ll_us_truck_*` | 海外采购及美国卡车运输 |
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。
- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。
- 表备注、字段备注和索引变更必须可重复执行;存量库的备注修订通过统一增量脚本落地,不依赖基线建表语句。
## 事件建单规则
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
@@ -43,8 +59,8 @@
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存;提交申请时将箱锁定为 `DISCARD_PENDING`,具备 `lastleg:cargo-discard:approve` 权限的审核人通过后才扣减 SKU 箱数并写逐箱流水,拒绝时释放锁箱。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;审核通过后后台才可维护入库量和新增出库记录。采购库存固定按 `inbound_quantity - outbound_quantity` 计算,禁止直接覆盖库存量或产生负库存;审核、编辑和出库均须按可信租户范围执行。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
## 修改与验证
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.discard;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.discard.mapper")
public class DiscardMapperConfiguration {
}
@@ -60,7 +60,10 @@ public interface CargoDiscardInventoryMapper {
@Param("skuCode") String skuCode);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") int packages);
@Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}")
Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** Final inventory mutation for a warehouse-approved cargo discard. */
@Service
public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Transactional(propagation = Propagation.MANDATORY)
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
List<CargoDiscardItem> items,
Map<Long, List<CargoDiscardCarton>> linksByItem,
String operatorId, LocalDateTime occurredAt) {
long beforePackages = 0;
long discardedPackages = 0;
long afterPackages = 0;
for (CargoDiscardItem item : items) {
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode());
long before = value(sku.getPackageCount());
int discarded = item.getDiscardPackages();
if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) {
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
}
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete");
for (CargoDiscardCarton link : links) {
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
}
Long persistedAfter = inventory.skuPackages(customerId, sku.getId());
long expectedAfter = before - discarded;
if (persistedAfter == null || persistedAfter != expectedAfter) {
throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode());
}
beforePackages = Math.addExact(beforePackages, before);
discardedPackages = Math.addExact(discardedPackages, discarded);
afterPackages = Math.addExact(afterPackages, persistedAfter);
}
if (beforePackages - discardedPackages != afterPackages
|| discardedPackages != application.getTotalPackages()) {
throw conflict("Cargo discard application package conservation check failed");
}
return new AdjustmentResult(beforePackages, discardedPackages, afterPackages);
}
private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, DropshipCarton carton,
String operatorId, LocalDateTime occurredAt) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
fact.setBusinessOrderNo(application.getDiscardNo());
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark()));
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt);
return fact;
}
private static long value(Long value) { return value == null ? 0L : value; }
private static String remarkSuffix(String value) {
return value == null || value.isBlank() ? "" : "; " + value.trim();
}
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) {
public String auditRemark(String reviewRemark) {
String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; ";
return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages
+ ", inventoryAfter=" + afterPackages;
}
}
}
@@ -17,9 +17,7 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -57,7 +55,7 @@ public class CargoDiscardService {
@Resource private CargoDiscardCartonMapper discardCartons;
@Resource private CargoDiscardStatusHistoryMapper histories;
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
@@ -175,14 +173,20 @@ public class CargoDiscardService {
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
if (APPROVED.equals(next)) approve(customerId, application, applicationItems, linksByItem, operatorId, now());
else release(customerId, links);
String historyRemark = request.remark();
if (APPROVED.equals(next)) {
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
customerId, application, applicationItems, linksByItem, operatorId, now());
historyRemark = result.auditRemark(request.remark());
} else {
release(customerId, links);
}
LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
application.setUpdatedAt(reviewedAt); applications.updateById(application);
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, request.remark(), reviewedAt);
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
return detail(customerId, discardNo);
}
@@ -219,31 +223,6 @@ public class CargoDiscardService {
}
}
private void approve(String customerId, CargoDiscardApplication application, List<CargoDiscardItem> applicationItems,
Map<Long, List<CargoDiscardCarton>> linksByItem, String operatorId, LocalDateTime occurredAt) {
for (CargoDiscardItem item : applicationItems) {
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null || inventory.deductSkuInventory(customerId, sku.getId(), item.getDiscardPackages()) != 1) {
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
}
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
if (links.size() != item.getDiscardPackages()) throw conflict("Reserved discard cartons are incomplete");
for (CargoDiscardCarton link : links) {
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
fact.setBusinessOrderNo(application.getDiscardNo());
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
fact.setRemark("Approved by " + operatorId + remarkSuffix(application.getRemark()));
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt); cartonLedgers.insert(fact);
}
}
}
private void release(String customerId, List<CargoDiscardCarton> links) {
for (CargoDiscardCarton link : links) {
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
@@ -295,7 +274,6 @@ public class CargoDiscardService {
private static LocalDateTime now() { return LocalDateTime.now(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
private static String remarkSuffix(String value) { String normalized = trim(value); return normalized == null ? "" : "; " + normalized; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
@@ -36,6 +36,9 @@ public interface DispatchCandidateMapper {
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.plan_id,p.sequence_no" +
@@ -63,6 +66,9 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
"ORDER BY p.id FOR UPDATE" +
@@ -202,7 +202,7 @@ public class DispatchPdaWorkflowService {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
pick.getInventoryPositionId(), n(pick.getPackages()),
order.getDispatchNo(), order.getDispatchNo(), null, key, request.remark()));
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
LocalDateTime now = LocalDateTime.now();
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.dropship;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.dropship.mapper")
public class DropshipMapperConfiguration {
}
@@ -7,6 +7,9 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence")
List<DropshipCarton> listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -5,7 +5,7 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests;
import com.baiyue.lastleg.dropship.entity.*;
import com.baiyue.lastleg.dropship.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -17,6 +17,8 @@ import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class DropshipOrderService {
@@ -25,7 +27,6 @@ public class DropshipOrderService {
@Resource private DropshipSkuInventoryMapper skuInventory;
@Resource private DropshipCartonMapper cartons;
@Resource private DropshipCartonLedgerMapper ledger;
@Resource private WarehouseResourceGateway warehouse;
@Transactional
public int receive(DropshipRequests.Receive request) {
@@ -55,9 +56,7 @@ public class DropshipOrderService {
validateMarks(request);
LocalDateTime now = LocalDateTime.now();
for (DropshipRequests.Pallet inputPallet : request.pallets()) {
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.ensureDropshipPallet(
request.customerId(), request.warehouseId(), request.orderId() + ":" + inputPallet.palletNo().trim());
DropshipOrderProfile profile = profile(request, order, inputPallet, pallet, now);
DropshipOrderProfile profile = profile(request, order, inputPallet, now);
profiles.insert(profile);
int sequence = 0;
for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) {
@@ -70,9 +69,9 @@ public class DropshipOrderService {
for (String mark : input.cartonMarks()) {
DropshipCarton carton = new DropshipCarton();
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(pallet.palletId()); carton.setPalletNo(pallet.palletNo());
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
carton.setStatus("AVAILABLE"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
ledger.insert(stockInFact(request, order, carton, now));
}
@@ -100,30 +99,85 @@ public class DropshipOrderService {
}
private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order,
DropshipRequests.Pallet input,
WarehouseResourceGateway.WarehousePalletReference pallet, LocalDateTime now) {
DropshipRequests.Pallet input, LocalDateTime now) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
profile.setWarehousePalletId(pallet.palletId()); profile.setPalletNo(pallet.palletNo());
profile.setSourcePalletNo(input.palletNo().trim());
profile.setStatus("RECEIVED"); profile.setVersion(0L);
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
profile.setCreatedAt(now); profile.setUpdatedAt(now);
return profile;
}
@Transactional
public void activateAfterStockIn(String customerId, Long orderId, Long taskId,
List<InboundStockReceipt> receipts) {
List<DropshipOrderProfile> profilesForOrder = profiles.listOrder(customerId, orderId);
if (profilesForOrder.isEmpty()) return;
if (profilesForOrder.stream().allMatch(profile -> "ACTIVE".equals(profile.getStatus()))) return;
if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) {
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
}
Map<Long, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
.filter(receipt -> receipt.getWarehousePalletId() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId));
if (receiptsByPallet.size() != profilesForOrder.size()) {
throw conflict("PDA pallet count does not match dropship source pallet count");
}
List<DropshipOrderProfile> orderedProfiles = profilesForOrder.stream()
.sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList();
List<List<InboundStockReceipt>> orderedReceipts = receiptsByPallet.values().stream()
.sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList();
LocalDateTime now = LocalDateTime.now();
for (int index = 0; index < orderedProfiles.size(); index++) {
DropshipOrderProfile profile = orderedProfiles.get(index);
List<InboundStockReceipt> actual = orderedReceipts.get(index);
List<DropshipCarton> profileCartons = cartons.listProfile(customerId, profile.getId());
long expected = profileCartons.size();
long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum();
if (expected != received) throw conflict("PDA package count does not match dropship carton snapshot");
Long positionId = actual.get(0).getInventoryPositionId();
if (actual.stream().anyMatch(value -> !java.util.Objects.equals(positionId, value.getInventoryPositionId()))) {
throw conflict("Dropship source pallet maps to multiple inventory positions");
}
InboundStockReceipt first = actual.get(0);
profile.setWarehousePalletId(first.getWarehousePalletId()); profile.setPalletNo(first.getPalletNo());
profile.setInventoryPositionId(positionId); profile.setStatus("ACTIVE"); profile.setVersion(value(profile.getVersion()) + 1);
profile.setUpdatedAt(now); profiles.updateById(profile);
for (DropshipCarton carton : profileCartons) {
carton.setWarehousePalletId(first.getWarehousePalletId()); carton.setPalletNo(first.getPalletNo());
carton.setStatus("AVAILABLE"); carton.setVersion(value(carton.getVersion()) + 1); carton.setUpdatedAt(now);
cartons.updateById(carton);
DropshipCartonLedger activation = stockInActivation(customerId, orderId, taskId, carton, now);
ledger.insert(activation);
}
}
}
private DropshipCartonLedger stockInActivation(String customerId, Long orderId, Long taskId,
DropshipCarton carton, LocalDateTime now) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(orderId);
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L);
fact.setIdempotencyKey("PDA_STOCK_IN:" + taskId + ":" + carton.getId()); fact.setOccurredAt(now); fact.setCreatedAt(now);
return fact;
}
private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order,
DropshipCarton carton, LocalDateTime now) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId());
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L);
fact.setOperationType("SNAPSHOT_CREATED"); fact.setBeforePackages(0L); fact.setDeltaPackages(0L); fact.setAfterPackages(0L);
fact.setBusinessOrderNo(order.getOrderNo());
fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark());
fact.setOccurredAt(now); fact.setCreatedAt(now);
return fact;
}
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
@@ -13,9 +13,11 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
@@ -23,6 +25,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** Converts final PDA package counts into inbound-order/pallet inventory. */
@@ -31,12 +34,12 @@ public class InboundStockReceiptService {
@Resource private LastlegOrderMapper orders;
@Resource private InboundStockReceiptMapper receipts;
@Resource private InventoryCommandService inventory;
@Resource private DropshipOrderService dropshipOrders;
@Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
if (!receipts.listTask(customerId, task.getId()).isEmpty()) return;
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> customerId.equals(order.getCustomerId()))
@@ -48,6 +51,20 @@ public class InboundStockReceiptService {
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId))
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId));
Set<Long> expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream)
.map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet());
List<InboundStockReceipt> existing = receipts.listTask(customerId, task.getId());
if (!existing.isEmpty()) {
Set<Long> receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (receivedAllocationIds.equals(expectedAllocationIds)) {
activateDropship(customerId, task.getId(), orderById, existing);
return;
}
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
}
List<InboundStockReceipt> created = new java.util.ArrayList<>();
for (InboundPlanLine line : lines) {
LastlegOrder order = orderById.get(line.getOrderId());
if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant");
@@ -59,13 +76,14 @@ public class InboundStockReceiptService {
throw conflict("PDA picked packages must equal the inbound order package total");
}
for (InboundPlanPalletAllocation allocation : lineAllocations) {
persist(customerId, plan, task, line, allocation,
requirePallet(palletById, allocation), order, receivedAt);
created.add(persist(customerId, plan, task, line, allocation,
requirePallet(palletById, allocation), order, receivedAt));
}
}
activateDropship(customerId, task.getId(), orderById, created);
}
private void persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
LastlegOrder order, LocalDateTime receivedAt) {
long packages = value(allocation.getPickedPackageCount());
@@ -106,6 +124,20 @@ public class InboundStockReceiptService {
receipt.setReceivedAt(receivedAt);
receipt.setCreatedAt(receivedAt);
receipts.insert(receipt);
return receipt;
}
private void activateDropship(String customerId, Long taskId, Map<Long, LastlegOrder> orderById,
List<InboundStockReceipt> stockReceipts) {
Map<Long, List<InboundStockReceipt>> receiptsByOrder = stockReceipts.stream()
.filter(receipt -> receipt.getOrderId() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getOrderId));
for (LastlegOrder order : orderById.values()) {
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
dropshipOrders.activateAfterStockIn(customerId, order.getId(), taskId,
receiptsByOrder.getOrDefault(order.getId(), List.of()));
}
}
}
private static InboundPlanPallet requirePallet(Map<Long, InboundPlanPallet> pallets,
@@ -175,6 +175,10 @@ public class InboundPlanService {
private void validateOrders(List<SourceOrderSnapshot> orders) {
for (SourceOrderSnapshot order : orders) {
if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
}
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode()
@@ -23,13 +23,6 @@ import org.springframework.web.server.ResponseStatusException;
public class InventoryCommandController {
@Resource private InventoryCommandService service;
@PostMapping("/stock-in")
@Operation(summary = "Put inbound-order packages into an overseas warehouse pallet")
public ApiResponse<InventoryCommandResponse> stockIn(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.StockIn request) {
return ApiResponse.success(service.stockIn(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/hold")
@Operation(summary = "Move available inbound-order packages to hold")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inventory.command.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
public final class InventoryCommandRequests {
@@ -26,7 +27,7 @@ public final class InventoryCommandRequests {
String batchNo,
Long warehouseLocationId,
String warehouseLocationCode,
@Positive long packages,
@Min(0) long packages,
String businessOrderNo,
String sourceEventId,
@NotBlank @Size(max = 255) String idempotencyKey,
@@ -328,6 +328,9 @@ public class InventoryCommandService {
ledger.setBeforePackages(beforePackages);
ledger.setDeltaPackages(deltaPackages);
ledger.setAfterPackages(afterPackages);
ledger.setBeforeQuantity(0L);
ledger.setDeltaQuantity(0L);
ledger.setAfterQuantity(0L);
ledger.setOperatorId(trim(operatorId));
ledger.setRemark(trim(remark));
ledger.setOccurredAt(LocalDateTime.now());
@@ -39,6 +39,10 @@ public class InventoryLedger {
private Long beforePackages;
private Long deltaPackages;
private Long afterPackages;
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
private Long beforeQuantity;
private Long deltaQuantity;
private Long afterQuantity;
private String operatorId;
private String remark;
private LocalDateTime occurredAt;
@@ -27,10 +27,10 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
int increasePicked(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages);
@Update("UPDATE ll_order SET picked_package_count=picked_package_count-#{packages}," +
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo}," +
"current_status=CASE WHEN shipped_package_count+#{packages}=package_count " +
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END,updated_at=NOW() " +
@Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " +
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," +
"picked_package_count=picked_package_count-#{packages}," +
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " +
"WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " +
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.procurement;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.procurement.mapper")
public class ProcurementMapperConfiguration {
}
@@ -37,6 +37,27 @@ public class WarehouseTransferController {
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
}
@PostMapping("/{transferNo}/allocations")
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Allocate request) {
return ApiResponse.success(service.allocate(scope(user, customerId), transferNo, operator(user), request));
}
@PostMapping("/{transferNo}/outbound")
public ApiResponse<WarehouseTransferResponses.Detail> outbound(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Outbound request) {
return ApiResponse.success(service.outbound(scope(user, customerId), transferNo, operator(user), request));
}
@PostMapping("/{transferNo}/receipts")
public ApiResponse<WarehouseTransferResponses.Detail> receive(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Receipt request) {
return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request));
}
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains("lastleg:warehouse-transfer:approve")) {
@@ -6,6 +6,8 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.DecimalMin;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@@ -19,6 +21,7 @@ public final class WarehouseTransferRequests {
@NotBlank @Size(max = 128) String fbaWarehouseCode,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt,
@NotBlank @Size(max = 128) String fbaShipmentNo,
@@ -33,6 +36,37 @@ public final class WarehouseTransferRequests {
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
public record Allocate(
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Allocation> allocations
) {}
public record Allocation(
@NotNull Long inventoryPositionId,
@NotNull Long palletId,
@Positive int packageCount
) {}
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Receipt(
@NotNull Long sourceInventoryPositionId,
@NotBlank @Size(max = 128) String destinationOverseasWarehouseId,
@Size(max = 128) String destinationOverseasWarehouseCode,
@Size(max = 255) String destinationOverseasWarehouseName,
@Size(max = 128) String destinationWarehouseId,
@NotBlank @Size(max = 128) String destinationWarehouseCode,
@Size(max = 255) String destinationWarehouseName,
@NotNull Long destinationPalletId,
@NotBlank @Size(max = 128) String destinationPalletNo,
@Size(max = 128) String batchNo,
Long warehouseLocationId,
@Size(max = 128) String warehouseLocationCode,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark
) {}
public record Item(
@NotBlank @Size(max = 128) String skuCode,
@NotBlank @Size(max = 255) String productNameZh,
@@ -7,11 +7,15 @@ public final class WarehouseTransferResponses {
private WarehouseTransferResponses() {}
public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {}
public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {}
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
Long palletId, String palletNo, int packageCount, String status,
Long destinationInventoryPositionId) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
List<SourceOrder> sourceOrders, List<Item> items, LocalDateTime createdAt) {}
List<SourceOrder> sourceOrders, List<Item> items, List<Allocation> allocations,
LocalDateTime outboundAt, LocalDateTime createdAt) {}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.transfer.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;
@Data
@TableName("ll_warehouse_transfer_inventory_allocation")
public class WarehouseTransferInventoryAllocation {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private String customerId;
private Long inventoryPositionId;
private Long inventoryBalanceId;
private Long inboundOrderId;
private String inboundCode;
private Long palletId;
private String palletNo;
private Integer packageCount;
private String status;
private String activePositionKey;
private LocalDateTime allocatedAt;
private LocalDateTime shippedAt;
private LocalDateTime receivedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -30,6 +30,11 @@ public class WarehouseTransferOrder {
private Long relabelApplicationId;
private String relabelNo;
private String status;
private String allocationIdempotencyKey;
private String allocationRequestHash;
private String outboundIdempotencyKey;
private String outboundRequestHash;
private LocalDateTime outboundAt;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.transfer.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;
@Data
@TableName("ll_warehouse_transfer_receipt")
public class WarehouseTransferReceipt {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private Long allocationId;
private String customerId;
private Long sourceInventoryPositionId;
private Long destinationInventoryBalanceId;
private Long destinationInventoryPositionId;
private Integer packageCount;
private String idempotencyKey;
private String requestHash;
private String inventoryOperationKey;
private String createdBy;
private LocalDateTime receivedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface WarehouseTransferInventoryAllocationMapper extends BaseMapper<WarehouseTransferInventoryAllocation> {
@Select("SELECT * FROM ll_warehouse_transfer_inventory_allocation WHERE customer_id=#{customerId} " +
"AND transfer_order_id=#{transferOrderId} ORDER BY id FOR UPDATE")
List<WarehouseTransferInventoryAllocation> lockTransfer(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@Select("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} " +
"AND warehouse_pallet_id=#{palletId} AND status='ACTIVE'")
int countActiveDispatchLock(@Param("customerId") String customerId, @Param("palletId") Long palletId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface WarehouseTransferReceiptMapper extends BaseMapper<WarehouseTransferReceipt> {
@Select("SELECT * FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customerId} " +
"AND transfer_order_id=#{transferOrderId} AND source_inventory_position_id=#{sourcePositionId} LIMIT 1")
WarehouseTransferReceipt findSource(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId,
@Param("sourcePositionId") Long sourcePositionId);
}
@@ -8,6 +8,12 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
@@ -19,15 +25,20 @@ import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -38,7 +49,12 @@ import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class WarehouseTransferService {
@@ -48,10 +64,16 @@ public class WarehouseTransferService {
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT";
public static final String ALLOCATED = "ALLOCATED";
public static final String OUTBOUNDED = "OUTBOUNDED";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String RECEIVED = "RECEIVED";
@Resource private WarehouseTransferOrderMapper transfers;
@Resource private WarehouseTransferItemMapper transferItems;
@Resource private WarehouseTransferSourceOrderMapper sourceOrders;
@Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Resource private WarehouseTransferReceiptMapper transferReceipts;
@Resource private InboundSnapshotMapper inboundSnapshots;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@@ -60,6 +82,8 @@ public class WarehouseTransferService {
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelStatusHistoryMapper relabelHistories;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private InventoryCommandService inventory;
@Resource private InventoryCommandMapper inventoryCommands;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -148,6 +172,21 @@ public class WarehouseTransferService {
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId))
.stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(),
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
Map<Long, WarehouseTransferReceipt> receiptByAllocation = transferReceipts.selectList(
new LambdaQueryWrapper<WarehouseTransferReceipt>().eq(WarehouseTransferReceipt::getCustomerId, customerId)
.eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId()))
.stream().collect(Collectors.toMap(WarehouseTransferReceipt::getAllocationId, Function.identity()));
List<WarehouseTransferResponses.Allocation> allocations = inventoryAllocations.selectList(
new LambdaQueryWrapper<WarehouseTransferInventoryAllocation>()
.eq(WarehouseTransferInventoryAllocation::getCustomerId, customerId)
.eq(WarehouseTransferInventoryAllocation::getTransferOrderId, transfer.getId())
.orderByAsc(WarehouseTransferInventoryAllocation::getId))
.stream().map(value -> {
WarehouseTransferReceipt receipt = receiptByAllocation.get(value.getId());
return new WarehouseTransferResponses.Allocation(value.getInventoryPositionId(), value.getInboundOrderId(),
value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(),
value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId());
}).toList();
String relabelStatus = null;
if (transfer.getRelabelNo() != null) {
RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo());
@@ -162,7 +201,8 @@ public class WarehouseTransferService {
relabelStatus,
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(),
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, transfer.getCreatedAt());
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations,
transfer.getOutboundAt(), transfer.getCreatedAt());
}
@Transactional
@@ -194,6 +234,194 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Allocate request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (ALLOCATED.equals(transfer.getStatus()) && key.equals(transfer.getAllocationIdempotencyKey())) {
if (!requestHash.equals(transfer.getAllocationRequestHash())) {
throw conflict("Allocation idempotency key was reused with different inventory positions");
}
return detail(customerId, transferNo);
}
if (!APPROVED.equals(transfer.getStatus())) {
throw conflict("Only an approved warehouse transfer can allocate inventory");
}
List<WarehouseTransferRequests.Allocation> requested = request.allocations().stream()
.sorted(java.util.Comparator.comparing(WarehouseTransferRequests.Allocation::inventoryPositionId)).toList();
Set<Long> uniquePositions = new HashSet<>();
long totalPackages = 0;
for (WarehouseTransferRequests.Allocation allocation : requested) {
if (!uniquePositions.add(allocation.inventoryPositionId())) {
throw badRequest("Inventory positions must be unique within a warehouse transfer");
}
totalPackages = Math.addExact(totalPackages, allocation.packageCount());
}
if (totalPackages != transfer.getPackageCount()) {
throw badRequest("Allocated package total must equal warehouse transfer package count");
}
Set<Long> sourceInboundIds = sourceOrders.selectList(new LambdaQueryWrapper<WarehouseTransferSourceOrder>()
.eq(WarehouseTransferSourceOrder::getCustomerId, customerId)
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()))
.stream().map(WarehouseTransferSourceOrder::getInboundOrderId).collect(Collectors.toSet());
if (sourceInboundIds.isEmpty()) throw conflict("Warehouse transfer has no source inbound orders");
LocalDateTime now = LocalDateTime.now();
for (WarehouseTransferRequests.Allocation source : requested) {
InventoryPosition position = inventoryCommands.lockPosition(customerId, source.inventoryPositionId());
if (position == null) throw notFound("Inventory position not found: " + source.inventoryPositionId());
InventoryBalance balance = inventoryCommands.lockBalance(customerId, position.getBalanceId());
if (balance == null) throw conflict("Inventory position balance is missing");
if (!source.palletId().equals(position.getPalletId())) {
throw conflict("Inventory position does not belong to pallet " + source.palletId());
}
if (inventoryAllocations.countActiveDispatchLock(customerId, position.getPalletId()) > 0) {
throw conflict("Inventory pallet is already locked by an active dispatch");
}
if (!sourceInboundIds.contains(balance.getInboundOrderId())) {
throw conflict("Inventory position is not linked to a selected source inbound order");
}
if (packages(position.getAvailablePackages()) < source.packageCount()) {
throw conflict("Inventory position does not have enough available packages");
}
WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation();
allocation.setId(IdWorker.getId()); allocation.setTransferOrderId(transfer.getId());
allocation.setCustomerId(customerId); allocation.setInventoryPositionId(position.getId());
allocation.setInventoryBalanceId(balance.getId()); allocation.setInboundOrderId(balance.getInboundOrderId());
allocation.setInboundCode(balance.getInboundCode()); allocation.setPalletId(position.getPalletId());
allocation.setPalletNo(position.getPalletNo()); allocation.setPackageCount(source.packageCount());
allocation.setStatus("RESERVED"); allocation.setActivePositionKey(customerId + ":" + position.getId());
allocation.setAllocatedAt(now); allocation.setCreatedAt(now); allocation.setUpdatedAt(now);
try {
inventoryAllocations.insert(allocation);
} catch (DuplicateKeyException exception) {
throw conflict("Inventory position is already reserved by another warehouse transfer");
}
}
transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key);
transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus("READY_TO_OUTBOUND"); order.setUpdatedAt(now);
orders.updateById(order);
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer inventory allocated", now);
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail outbound(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Outbound request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus())
&& key.equals(transfer.getOutboundIdempotencyKey())) {
if (!requestHash.equals(transfer.getOutboundRequestHash())) {
throw conflict("Outbound idempotency key was reused with different data");
}
return detail(customerId, transferNo);
}
if (!ALLOCATED.equals(transfer.getStatus())) {
throw conflict("Warehouse transfer inventory must be allocated before outbound");
}
List<WarehouseTransferInventoryAllocation> allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId());
if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum()
!= transfer.getPackageCount()) {
throw conflict("Warehouse transfer inventory allocation is incomplete");
}
LocalDateTime now = LocalDateTime.now();
for (WarehouseTransferInventoryAllocation allocation : allocations) {
if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed");
String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId();
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId,
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark()));
inventory.allocate(customerId, operatorId, move);
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark())));
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark())));
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation);
}
transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key);
transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); order.setUpdatedAt(now);
orders.updateById(order);
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer left source warehouse", now);
return detail(customerId, transferNo);
}
@Transactional
public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Receipt request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!Set.of(OUTBOUNDED, PARTIALLY_RECEIVED).contains(transfer.getStatus())) {
if (RECEIVED.equals(transfer.getStatus())) {
WarehouseTransferReceipt replay = transferReceipts.findSource(customerId, transfer.getId(),
request.sourceInventoryPositionId());
if (replay != null && replay.getRequestHash().equals(hash(request))) return detail(customerId, transferNo);
}
throw conflict("Only outbound warehouse transfer inventory can be received");
}
List<WarehouseTransferInventoryAllocation> allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId());
WarehouseTransferInventoryAllocation allocation = allocations.stream()
.filter(value -> request.sourceInventoryPositionId().equals(value.getInventoryPositionId()))
.findFirst().orElseThrow(() -> notFound("Source inventory position is not allocated to this transfer"));
String requestHash = hash(request);
WarehouseTransferReceipt existing = transferReceipts.findSource(customerId, transfer.getId(),
request.sourceInventoryPositionId());
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) {
throw conflict("Transfer source position was already received with different destination data");
}
return detail(customerId, transferNo);
}
if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt");
String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId();
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, null,
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo,
request.destinationPalletId(), request.destinationPalletNo().trim(),
trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(),
trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo,
"WAREHOUSE_TRANSFER_RECEIPT:" + transferNo, operationKey, trim(request.remark())));
LocalDateTime now = LocalDateTime.now();
WarehouseTransferReceipt receipt = new WarehouseTransferReceipt();
receipt.setId(IdWorker.getId()); receipt.setTransferOrderId(transfer.getId()); receipt.setAllocationId(allocation.getId());
receipt.setCustomerId(customerId); receipt.setSourceInventoryPositionId(allocation.getInventoryPositionId());
receipt.setDestinationInventoryBalanceId(result.balanceId()); receipt.setDestinationInventoryPositionId(result.positionId());
receipt.setPackageCount(allocation.getPackageCount()); receipt.setIdempotencyKey(request.idempotencyKey().trim());
receipt.setRequestHash(requestHash); receipt.setInventoryOperationKey(operationKey); receipt.setCreatedBy(trim(operatorId));
receipt.setReceivedAt(now); receipt.setCreatedAt(now); transferReceipts.insert(receipt);
allocation.setStatus("RECEIVED"); allocation.setReceivedAt(now); allocation.setUpdatedAt(now);
inventoryAllocations.updateById(allocation);
boolean complete = allocations.stream().allMatch(value -> value.getId().equals(allocation.getId())
|| "RECEIVED".equals(value.getStatus()));
transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer);
String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? RECEIVED : PARTIALLY_RECEIVED);
order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
"Warehouse transfer inventory received at destination", now);
return detail(customerId, transferNo);
}
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
@@ -203,8 +431,8 @@ public class WarehouseTransferService {
order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId));
order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim());
order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount());
order.setContainerNo(transferNo); order.setTransferWarehouseId(request.operatingWarehouseId().trim());
order.setTransferWarehouseCode(request.operatingWarehouseId().trim());
order.setVolumeCbm(request.volumeCbm());
order.setContainerNo(transferNo);
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setCurrentStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark()));
@@ -277,6 +505,14 @@ public class WarehouseTransferService {
history.setCreatedAt(now); orderHistories.insert(history);
}
private LastlegOrder requireTailOrder(String customerId, WarehouseTransferOrder transfer) {
LastlegOrder order = orders.selectById(transfer.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) {
throw conflict("Linked tail-leg order is missing");
}
return order;
}
private String hash(Object value) {
try {
byte[] json = objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8);
@@ -286,6 +522,7 @@ public class WarehouseTransferService {
}
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static long packages(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.trucking;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.trucking.mapper")
public class TruckingMapperConfiguration {
}
@@ -13,6 +13,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -46,6 +47,14 @@ public class PlatformUsTruckOrderController {
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/receive-pallets")
public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) {
requirePermission(user);
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
@@ -9,18 +9,20 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public final class UsTruckOrderRequests {
private UsTruckOrderRequests() {}
public record Create(
@NotBlank @Size(max = 32) String transportType,
@Size(max = 32) String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@NotBlank @Size(max = 32) String containerType,
@NotNull @Min(0) Integer packageCount,
@Size(max = 32) String containerType,
@NotNull @Min(1) Integer palletCount,
@Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@@ -29,7 +31,19 @@ public final class UsTruckOrderRequests {
@NotBlank @Size(max = 128) String contactInfo,
@NotNull LocalDate requestedDate,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) {}
@NotBlank @Size(max = 255) String idempotencyKey) {
/** Backward-compatible constructor for clients still sending legacy transport fields. */
public Create(String transportType, String origin, String originAddress, String destination,
String destinationAddress, String containerType, Integer packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String remark, String idempotencyKey) {
this(transportType, origin, originAddress, destination, destinationAddress, containerType,
packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount,
totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo,
requestedDate, remark, idempotencyKey);
}
}
public record Query(
@Min(1) long page, @Min(1) @Max(200) long size,
@@ -44,6 +58,7 @@ public final class UsTruckOrderRequests {
@Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Min(1) Integer palletCount,
@Min(0) Integer packageCount,
@DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
@@ -58,4 +73,17 @@ public final class UsTruckOrderRequests {
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@Size(max = 1000) String remark) {}
public record ReceivePallets(
@NotNull @Min(0) Long version,
@NotNull @Min(1) Integer actualPalletCount,
@Min(0) Integer packageCount,
@Size(max = 500) List<@Min(0) Integer> palletPackageCounts,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {
public ReceivePallets(Long version, Integer actualPalletCount, Integer packageCount,
String idempotencyKey, String remark) {
this(version, actualPalletCount, packageCount, List.of(), idempotencyKey, remark);
}
}
}
@@ -10,7 +10,8 @@ public final class UsTruckOrderResponses {
public record Summary(
Long id, String customerId, String orderNo, String transportType,
String origin, String destination, String containerType, int packageCount,
String origin, String destination, String containerType, int palletCount, int packageCount,
int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
@@ -18,7 +19,10 @@ public final class UsTruckOrderResponses {
public record History(
String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {}
public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status,
Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {}
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
String createdBy, List<History> histories) {}
String createdBy, List<Pallet> pallets, List<History> histories) {}
}
@@ -21,7 +21,11 @@ public class UsTruckOrder {
private String destination;
private String destinationAddress;
private String containerType;
private Integer palletCount;
private Integer packageCount;
private Integer receivedPalletCount;
private Integer allocatedPalletCount;
private Integer shippedPalletCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
@@ -33,6 +37,8 @@ public class UsTruckOrder {
private String remark;
private String idempotencyKey;
private String requestHash;
private String receiptIdempotencyKey;
private String receiptRequestHash;
private String createdBy;
private Long version;
private LocalDateTime createdAt;
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.trucking.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;
@Data
@TableName("ll_us_truck_order_pallet")
public class UsTruckOrderPallet {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String palletNo;
private Integer sequenceNo;
private String source;
private String status;
private Long warehousePalletId;
private Integer packageCount;
private LocalDateTime receivedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.trucking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface UsTruckOrderPalletMapper extends BaseMapper<UsTruckOrderPallet> {
@Select("SELECT * FROM ll_us_truck_order_pallet WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY sequence_no")
List<UsTruckOrderPallet> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -7,8 +7,10 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -34,17 +36,39 @@ import java.util.function.Consumer;
@Service
public class UsTruckOrderService {
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
public static final String PENDING_CONFIRMATION = "PENDING_CONFIRMATION";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
/** Kept for source compatibility; new orders use PENDING_REVIEW. */
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
public static final String WAITING_INBOUND = "WAITING_INBOUND";
public static final String RECEIVING = "RECEIVING";
public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION";
public static final String PALLET_OVERAGE_REVIEW = "PALLET_OVERAGE_REVIEW";
public static final String PARTIALLY_ALLOCATED = "PARTIALLY_ALLOCATED";
public static final String ALLOCATED = "ALLOCATED";
public static final String PENDING_DISPATCH = "PENDING_DISPATCH";
public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED";
public static final String REJECTED = "REJECTED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_CONFIRMATION, PENDING_DISPATCH, DISPATCHED,
IN_TRANSIT, COMPLETED, CANCELLED);
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final java.util.Map<String, Set<String>> TRANSITIONS = java.util.Map.ofEntries(
java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)),
java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)),
java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)),
java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)),
java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)),
java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)),
java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)),
java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)),
java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)),
java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED)));
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
@Resource private UsTruckOrderMapper orders;
@Resource private UsTruckOrderPalletMapper pallets;
@Resource private UsTruckOrderStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@@ -54,20 +78,22 @@ public class UsTruckOrderService {
String hash = hash(request);
UsTruckOrder replay = orders.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, hash);
validateTransport(request.transportType());
if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType());
LocalDateTime now = LocalDateTime.now();
UsTruckOrder order = new UsTruckOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setTransportType(request.transportType().trim().toUpperCase());
order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null);
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
order.setContainerType(request.containerType().trim().toUpperCase()); order.setPackageCount(request.packageCount());
order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null);
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_CONFIRMATION);
order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW);
order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
try { orders.insert(order); }
@@ -76,7 +102,7 @@ public class UsTruckOrderService {
if (concurrent != null) return replay(customerId, concurrent, hash);
throw e;
}
history(order, null, PENDING_CONFIRMATION, operatorId, request.remark(), now);
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
@@ -110,11 +136,13 @@ public class UsTruckOrderService {
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_CONFIRMATION.equals(order.getStatus())) throw conflict("Only pending orders can be edited");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited");
if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
setText(request.containerType(), v -> order.setContainerType(norm(v))); if (request.packageCount() != null) order.setPackageCount(request.packageCount());
setText(request.containerType(), v -> order.setContainerType(norm(v)));
if (request.palletCount() != null) order.setPalletCount(request.palletCount());
if (request.packageCount() != null) order.setPackageCount(request.packageCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
@@ -128,15 +156,112 @@ public class UsTruckOrderService {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
String from = order.getStatus(); if (!next.equals(from)) { order.setStatus(next); touch(order); orders.updateById(order); history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); }
String from = order.getStatus();
if (!next.equals(from)) {
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
throw conflict("Unsupported truck order transition: " + from + " -> " + next);
}
order.setStatus(next);
if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order);
touch(order); orders.updateById(order);
history(order, from, next, operatorId, request.remark(), LocalDateTime.now());
}
return detail(customerId, orderNo);
}
@Transactional
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ReceivePallets request) {
UsTruckOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (key.equals(order.getReceiptIdempotencyKey())) {
if (!requestHash.equals(order.getReceiptRequestHash())) {
throw conflict("Receipt idempotency key was reused with different pallet data");
}
return detail(customerId, orderNo);
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) {
throw conflict("Only waiting or receiving orders can receive pallets");
}
createPlannedPallets(order);
List<UsTruckOrderPallet> current = pallets.listOrder(customerId, order.getId());
int planned = value(order.getPalletCount());
int actual = request.actualPalletCount();
List<Integer> packageCounts = request.palletPackageCounts() == null ? List.of()
: request.palletPackageCounts();
if (!packageCounts.isEmpty() && packageCounts.size() != actual) {
throw badRequest("palletPackageCounts must be empty or contain one value per actual pallet");
}
if (!packageCounts.isEmpty() && request.packageCount() != null
&& packageCounts.stream().mapToInt(Integer::intValue).sum() != request.packageCount()) {
throw badRequest("Per-pallet package counts must equal the received package total");
}
LocalDateTime now = LocalDateTime.now();
for (int sequence = 1; sequence <= actual; sequence++) {
UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1)
: newPallet(order, sequence, "PDA_OVERAGE", now);
pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED");
if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1));
else if (actual == 1) pallet.setPackageCount(request.packageCount());
pallet.setReceivedAt(now);
pallet.setUpdatedAt(now);
if (pallet.getId() == null) {
pallet.setId(IdWorker.getId());
pallets.insert(pallet);
} else {
pallets.updateById(pallet);
}
}
order.setReceivedPalletCount(actual);
order.setReceiptIdempotencyKey(key);
order.setReceiptRequestHash(requestHash);
String from = order.getStatus();
String next = actual > planned ? PALLET_OVERAGE_REVIEW
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
order.setStatus(next);
touch(order);
orders.updateById(order);
history(order, from, next, operatorId, request.remark(), now);
return detail(customerId, orderNo);
}
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) { return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), o.getRemark(), o.getCreatedBy(), hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList()); }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPackageCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
List<UsTruckOrderResponses.Pallet> palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream()
.map(this::pallet)
.toList();
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
o.getRemark(), o.getCreatedBy(), palletDetails,
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(),
h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList());
}
private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
}
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), value(o.getShippedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); }
private void createPlannedPallets(UsTruckOrder order) {
int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size();
LocalDateTime now = LocalDateTime.now();
for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) {
UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now);
pallet.setId(IdWorker.getId());
pallets.insert(pallet);
}
}
private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) {
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId());
pallet.setPalletNo(order.getOrderNo() + "-P" + String.format("%03d", sequence));
pallet.setSequenceNo(sequence); pallet.setSource(source); pallet.setStatus("EXPECTED");
pallet.setCreatedAt(now); pallet.setUpdatedAt(now);
return pallet;
}
private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); }
private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); }
private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import org.mybatis.spring.annotation.MapperScan;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = CargoDiscardMysqlAcceptanceTest.AcceptanceApplication.class, properties = {
"spring.sql.init.mode=never",
"spring.cloud.nacos.discovery.enabled=false",
"spring.cloud.nacos.discovery.register-enabled=false"
})
@EnabledIfSystemProperty(named = "cargo.discard.acceptance", matches = "true")
class CargoDiscardMysqlAcceptanceTest {
private static final String CUSTOMER = "990001";
@Autowired private JdbcTemplate jdbc;
@Autowired private CargoDiscardService service;
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({CargoDiscardService.class, CargoDiscardInventoryAdjustmentService.class})
@MapperScan({
"com.baiyue.lastleg.mapper",
"com.baiyue.lastleg.discard.mapper",
"com.baiyue.lastleg.dropship.mapper"
})
static class AcceptanceApplication {
}
@Test
void createApprovalRejectionPendingAndInsufficientStockScenarios() {
resetAcceptanceTenant();
seedOrder(990001001L, "RK-ACCEPT-APPROVE", "JF-ACCEPT-001", 20);
seedProfile(990001101L, 990001001L, "PALLET-A");
seedSku(990001201L, 990001001L, 990001101L, "SKU-BOX-RED", "红色折叠收纳箱", 12);
seedSku(990001202L, 990001001L, 990001101L, "SKU-RACK-KITCHEN", "厨房置物架", 8);
seedCartons(990001001L, 990001101L, 990001301L, "PALLET-A", "SKU-BOX-RED", "RED", 12);
seedCartons(990001001L, 990001101L, 990001401L, "PALLET-A", "SKU-RACK-KITCHEN", "RACK", 8);
seedOrder(990001002L, "RK-ACCEPT-REJECT", "JF-ACCEPT-002", 10);
seedProfile(990001102L, 990001002L, "PALLET-B");
seedSku(990001203L, 990001002L, 990001102L, "SKU-LAMP-WHITE", "白色台灯", 10);
seedCartons(990001002L, 990001102L, 990001501L, "PALLET-B", "SKU-LAMP-WHITE", "LAMP", 10);
seedOrder(990001003L, "RK-ACCEPT-PENDING", "JF-ACCEPT-003", 7);
seedProfile(990001103L, 990001003L, "PALLET-C");
seedSku(990001204L, 990001003L, 990001103L, "SKU-MAT-BLUE", "蓝色瑜伽垫", 7);
seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7);
var approveRequest = new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3),
new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)),
"ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃");
var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest);
assertEquals(20L, skuPackages(990001001L));
approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer",
new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实"));
assertEquals(CargoDiscardService.APPROVED, approved.summary().status());
assertEquals(15L, skuPackages(990001001L));
var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)),
"ACCEPT-DISCARD-REJECT", "验收:外箱可修复"));
assertEquals(10L, skuPackages(990001002L));
rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer",
new CargoDiscardRequests.Review("REJECT", "复核后无需废弃"));
assertEquals(CargoDiscardService.REJECTED, rejected.summary().status());
assertEquals(10L, skuPackages(990001002L));
var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)),
"ACCEPT-DISCARD-PENDING", "验收:等待仓库审核"));
assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status());
assertEquals(7L, skuPackages(990001003L));
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator",
new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)),
"ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败")));
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'"));
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='REJECTED'"));
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='PENDING_REVIEW'"));
assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001001 AND status='DISCARDED'"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001002 AND status='DISCARD_PENDING'"));
assertEquals(2, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001003 AND status='DISCARD_PENDING'"));
assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'"));
assertEquals(5, count("SELECT COUNT(DISTINCT idempotency_key) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'"));
String audit = jdbc.queryForObject("SELECT remark FROM ll_cargo_discard_status_history WHERE customer_id=? " +
"AND to_status='APPROVED'", String.class, CUSTOMER);
assertTrue(audit.contains("inventoryBefore=20, discarded=5, inventoryAfter=15"));
}
private void resetAcceptanceTenant() {
jdbc.update("DELETE FROM ll_dropship_carton_ledger WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_status_history WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_carton WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_item WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_cargo_discard_application WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_dropship_carton WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_dropship_sku_inventory WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_dropship_order_profile WHERE customer_id=?", CUSTOMER);
jdbc.update("DELETE FROM ll_order WHERE customer_id=?", CUSTOMER);
}
private void seedOrder(long id, String inboundCode, String orderNo, int packages) {
jdbc.update("INSERT INTO ll_order(id,order_no,customer_id,order_type,inbound_code,source_region," +
"source_inbound_id,customer_name,overseas_warehouse_id,overseas_warehouse_name,pallet_count," +
"package_count,product_quantity,current_status,source_event_id,source_loading_plan_id,source_payload_hash," +
"inbound_order_no,created_at,updated_at) VALUES(?,?,?,'ONE_CLICK_DROPSHIP',?,'ACCEPTANCE',?," +
"'废弃验收客户','WH-US-LAX','美西洛杉矶仓',1,?,?, 'WAREHOUSED',?,?,?, ?,NOW(3),NOW(3))",
id, orderNo, CUSTOMER, inboundCode, "ACCEPT-" + id, packages, packages * 10,
"ACCEPT-EVENT-" + id, "ACCEPT-PLAN-" + id, "sha256:acceptance-" + id, orderNo);
}
private void seedProfile(long id, long orderId, String palletNo) {
jdbc.update("INSERT INTO ll_dropship_order_profile(id,customer_id,order_id,warehouse_pallet_id,pallet_no," +
"source_pallet_no,status,idempotency_key,version,created_at,updated_at) " +
"VALUES(?,?,?, ?,?,?,'RECEIVED',?,0,NOW(3),NOW(3))",
id, CUSTOMER, orderId, id, palletNo, "SRC-" + palletNo, "ACCEPT-PROFILE-" + id);
}
private void seedSku(long id, long orderId, long profileId, String sku, String name, int packages) {
jdbc.update("INSERT INTO ll_dropship_sku_inventory(id,customer_id,order_id,profile_id,sku_code,product_name," +
"package_count,product_quantity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,NOW(3),NOW(3))",
id, CUSTOMER, orderId, profileId, sku, name, packages, packages * 10);
}
private void seedCartons(long orderId, long profileId, long firstId, String palletNo,
String sku, String markPrefix, int packages) {
for (int index = 1; index <= packages; index++) {
jdbc.update("INSERT INTO ll_dropship_carton(id,customer_id,order_id,profile_id,warehouse_pallet_id," +
"pallet_no,sku_code,carton_mark,carton_sequence,status,version,created_at,updated_at) " +
"VALUES(?,?,?,?,?,?,?,?,?,'AVAILABLE',0,NOW(3),NOW(3))",
firstId + index - 1, CUSTOMER, orderId, profileId, profileId, palletNo, sku,
"ACCEPT-" + markPrefix + "-" + String.format("%03d", index), index);
}
}
private long skuPackages(long orderId) {
return jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dropship_sku_inventory WHERE customer_id=? AND order_id=?",
Long.class, CUSTOMER, orderId);
}
private int count(String sql) {
return jdbc.queryForObject(sql, Integer.class, CUSTOMER);
}
}
@@ -0,0 +1,108 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CargoDiscardInventoryAdjustmentServiceTest {
@Mock CargoDiscardInventoryMapper inventory;
@Mock DropshipCartonLedgerMapper ledgers;
private CargoDiscardInventoryAdjustmentService service;
@BeforeEach
void setUp() {
service = new CargoDiscardInventoryAdjustmentService();
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "cartonLedgers", ledgers);
}
@Test
void discardRequiresExistingTransactionAndConservesPackages() throws Exception {
Transactional transactional = CargoDiscardInventoryAdjustmentService.class
.getMethod("discard", String.class, CargoDiscardApplication.class, List.class, Map.class,
String.class, LocalDateTime.class).getAnnotation(Transactional.class);
assertEquals(Propagation.MANDATORY, transactional.propagation());
CargoDiscardApplication application = application(2);
CargoDiscardItem item = item(2);
when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5));
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
when(inventory.skuPackages("100", 301L)).thenReturn(3L);
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
var result = service.discard("100", application, List.of(item),
Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now());
assertEquals(5, result.beforePackages());
assertEquals(2, result.discardedPackages());
assertEquals(3, result.afterPackages());
ArgumentCaptor<DropshipCartonLedger> facts = ArgumentCaptor.forClass(DropshipCartonLedger.class);
verify(ledgers, times(2)).insert(facts.capture());
assertTrue(facts.getAllValues().stream().allMatch(value -> "DISCARD".equals(value.getOperationType())
&& value.getIdempotencyKey().startsWith("CARGO_DISCARD:401:")));
}
@Test
void discardRejectsAnyPackageConservationMismatch() {
CargoDiscardApplication application = application(2);
CargoDiscardItem item = item(2);
when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5));
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
when(inventory.skuPackages("100", 301L)).thenReturn(4L);
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
assertThrows(ResponseStatusException.class, () -> service.discard("100", application, List.of(item),
Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now()));
}
private CargoDiscardApplication application(int packages) {
CargoDiscardApplication value = new CargoDiscardApplication();
value.setId(401L); value.setDiscardNo("FH-1"); value.setTotalPackages(packages); value.setRemark("damaged");
return value;
}
private CargoDiscardItem item(int packages) {
CargoDiscardItem value = new CargoDiscardItem();
value.setId(501L); value.setOrderId(101L); value.setSkuInventoryId(301L); value.setSkuCode("SKU-1");
value.setDiscardPackages(packages); return value;
}
private DropshipSkuInventory sku(long packages) {
DropshipSkuInventory value = new DropshipSkuInventory(); value.setId(301L); value.setPackageCount(packages); return value;
}
private CargoDiscardCarton link(long id) {
CargoDiscardCarton value = new CargoDiscardCarton(); value.setItemId(501L); value.setCartonId(id); return value;
}
private DropshipCarton carton(long id) {
DropshipCarton value = new DropshipCarton(); value.setId(id); value.setCartonMark("C-" + id); return value;
}
}
@@ -12,7 +12,6 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,7 +45,7 @@ class CargoDiscardServiceTest {
@Mock CargoDiscardCartonMapper discardCartons;
@Mock CargoDiscardStatusHistoryMapper histories;
@Mock CargoDiscardInventoryMapper inventory;
@Mock DropshipCartonLedgerMapper cartonLedgers;
@Mock CargoDiscardInventoryAdjustmentService adjustments;
@Mock LastlegOrderMapper orders;
private CargoDiscardService service;
@@ -63,7 +62,7 @@ class CargoDiscardServiceTest {
ReflectionTestUtils.setField(service, "discardCartons", discardCartons);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "cartonLedgers", cartonLedgers);
ReflectionTestUtils.setField(service, "adjustments", adjustments);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00"));
@@ -107,17 +106,15 @@ class CargoDiscardServiceTest {
storedLinks.add(link(item.getId(), 1L));
storedLinks.add(link(item.getId(), 2L));
when(applications.lockNo("100", "FH-1")).thenReturn(application);
when(inventory.lockSkuInventory("100", 301L)).thenReturn(skuInventory());
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
when(adjustments.discard(eq("100"), eq(application), any(), any(), eq("9"), any()))
.thenReturn(new CargoDiscardInventoryAdjustmentService.AdjustmentResult(3, 2, 1));
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
verify(inventory).deductSkuInventory("100", 301L, 2);
verify(cartonLedgers, times(2)).insert(any(com.baiyue.lastleg.dropship.entity.DropshipCartonLedger.class));
verify(inventory, times(2)).changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"));
verify(adjustments).discard(eq("100"), eq(application), any(), any(), eq("9"), any());
assertEquals("ok; inventoryBefore=3, discarded=2, inventoryAfter=1",
storedHistories.get(storedHistories.size() - 1).getRemark());
}
@Test
@@ -134,7 +131,7 @@ class CargoDiscardServiceTest {
assertEquals(CargoDiscardService.REJECTED, detail.summary().status());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
verify(cartonLedgers, never()).insert(any(com.baiyue.lastleg.dropship.entity.DropshipCartonLedger.class));
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
}
private LastlegOrder order() {
@@ -21,4 +21,13 @@ class DispatchSchemaContractTest {
assertTrue(sql.contains("active_key"));
assertTrue(sql.contains("order_refs"));
}
@Test
void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception {
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java"));
assertTrue(mapper.contains("ll_warehouse_transfer_inventory_allocation"));
assertTrue(mapper.contains("ta.status='RESERVED'"));
assertTrue(mapper.contains("tp.pallet_id=p.warehouse_pallet_id"));
}
}
@@ -8,6 +8,7 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -18,8 +19,10 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -29,6 +32,16 @@ class DispatchPickOrderAttributionServiceTest {
@Mock private LastlegOrderMapper orders;
@Mock private LastlegOrderStatusHistoryMapper history;
@Test
void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException {
Update update = LastlegOrderMapper.class.getMethod("transferPickedToShipped",
String.class, Long.class, long.class, String.class).getAnnotation(Update.class);
String sql = String.join("", Arrays.asList(update.value())).replaceAll("\\s+", " ");
assertTrue(sql.indexOf("current_status=CASE")
< sql.indexOf("shipped_package_count=shipped_package_count+"));
}
@Test
void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() {
DispatchPickOrderAttributionService service = service();
@@ -20,11 +20,16 @@ class DropshipSchemaContractTest {
"key idx_ll_order_container(customer_id,container_no)",
"unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
"unique key uk_ll_dropship_sku_inventory(profile_id,sku_code)",
"key idx_ll_dropship_profile_order(customer_id,order_id)");
"key idx_ll_dropship_profile_order(customer_id,order_id)",
"warehouse_pallet_id bigint null comment '仓库实物托盘id'",
"pallet_no varchar(128) null comment '托盘号'",
"status varchar(32) not null default 'pending_inbound'");
assertThat(migration).contains(
"add key idx_ll_order_container (customer_id,container_no)",
"add unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
"add unique key uk_ll_dropship_sku_profile(profile_id,sku_code)",
"add key idx_ll_dropship_profile_order(customer_id,order_id)");
"add key idx_ll_dropship_profile_order(customer_id,order_id)",
"modify column warehouse_pallet_id bigint null",
"modify column pallet_no varchar(128) null");
}
}
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -46,9 +47,6 @@ class DropshipOrderServiceTest {
order.setProductQuantity(30);
when(orders.selectById(10L)).thenReturn(order);
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of());
when(warehouse.ensureDropshipPallet(eq("C-1"), eq(20L), anyString()))
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(101L, "WH-P-1", 1L))
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(102L, "WH-P-2", 1L));
DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-1",
List.of(
@@ -60,7 +58,7 @@ class DropshipOrderServiceTest {
List.of("MARK-3"))))));
assertThat(service.receive(request)).isEqualTo(3);
verify(warehouse, times(2)).ensureDropshipPallet(eq("C-1"), eq(20L), anyString());
verifyNoInteractions(warehouse);
verify(profiles, times(2)).insert(any(DropshipOrderProfile.class));
verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class));
verify(cartons, times(3)).insert(any(DropshipCarton.class));
@@ -72,5 +70,79 @@ class DropshipOrderServiceTest {
.containsExactly("SOURCE-P-1", "SOURCE-P-2");
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getCustomerId)
.containsOnly("C-1");
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getStatus)
.containsOnly("PENDING_INBOUND");
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getWarehousePalletId)
.containsOnlyNulls();
ArgumentCaptor<DropshipCarton> cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class);
verify(cartons, times(3)).insert(cartonCaptor.capture());
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus)
.containsOnly("PENDING_INBOUND");
ArgumentCaptor<DropshipCartonLedger> ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class);
verify(ledger, times(3)).insert(ledgerCaptor.capture());
assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getOperationType)
.containsOnly("SNAPSHOT_CREATED");
assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages)
.containsOnly(0L);
}
@Test
void activatesMultiplePalletsOnlyAfterPdaStockIn() {
DropshipOrderProfile first = profile(1L, "SOURCE-P-1");
DropshipOrderProfile second = profile(2L, "SOURCE-P-2");
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(first, second));
DropshipCarton firstCarton = carton(11L, 1L, "MARK-1");
DropshipCarton secondCarton = carton(12L, 2L, "MARK-2");
when(cartons.listProfile("C-1", 1L)).thenReturn(List.of(firstCarton));
when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton));
service.activateAfterStockIn("C-1", 10L, 50L, List.of(
receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1)));
assertThat(first.getStatus()).isEqualTo("ACTIVE");
assertThat(first.getWarehousePalletId()).isEqualTo(101L);
assertThat(firstCarton.getStatus()).isEqualTo("AVAILABLE");
assertThat(firstCarton.getWarehousePalletId()).isEqualTo(101L);
verify(profiles, times(2)).updateById(any(DropshipOrderProfile.class));
verify(cartons, times(2)).updateById(any(DropshipCarton.class));
ArgumentCaptor<DropshipCartonLedger> facts = ArgumentCaptor.forClass(DropshipCartonLedger.class);
verify(ledger, times(2)).insert(facts.capture());
assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getOperationType).containsOnly("STOCK_IN");
assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages).containsOnly(1L);
}
@Test
void repeatedPdaActivationIsANoOp() {
DropshipOrderProfile active = profile(1L, "SOURCE-P-1");
active.setStatus("ACTIVE");
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(active));
service.activateAfterStockIn("C-1", 10L, 50L,
List.of(receipt(101L, "WH-P-1", 201L, 1)));
verify(profiles, never()).updateById(any(DropshipOrderProfile.class));
verify(cartons, never()).updateById(any(DropshipCarton.class));
verify(ledger, never()).insert(any(DropshipCartonLedger.class));
}
private static DropshipOrderProfile profile(long id, String sourcePalletNo) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(id); profile.setCustomerId("C-1"); profile.setOrderId(10L);
profile.setSourcePalletNo(sourcePalletNo); profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
return profile;
}
private static DropshipCarton carton(long id, long profileId, String mark) {
DropshipCarton carton = new DropshipCarton();
carton.setId(id); carton.setCustomerId("C-1"); carton.setOrderId(10L); carton.setProfileId(profileId);
carton.setCartonMark(mark); carton.setSkuCode("SKU-A"); carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L);
return carton;
}
private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) {
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo);
receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages);
return receipt;
}
}
@@ -13,6 +13,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -41,6 +42,7 @@ class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
private InboundStockReceiptService service;
@BeforeEach
@@ -49,6 +51,7 @@ class InboundStockReceiptServiceTest {
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
when(receipts.listTask("100", 2L)).thenReturn(List.of());
}
@@ -110,14 +113,58 @@ class InboundStockReceiptServiceTest {
@Test
void existingTaskReceiptMakesStockInReplayANoOp() {
Fixture fixture = fixture(10, 5, 5, 10);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
InboundStockReceipt firstReceipt = new InboundStockReceipt();
firstReceipt.setAllocationId(fixture.allocation.getId()); firstReceipt.setOrderId(fixture.order.getId());
InboundStockReceipt secondReceipt = new InboundStockReceipt();
secondReceipt.setAllocationId(secondAllocation.getId()); secondReceipt.setOrderId(fixture.order.getId());
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(firstReceipt, secondReceipt));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
}
@Test
void partialTaskReceiptsAreRejected() {
Fixture fixture = fixture(10, 5, 5, 10);
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
InboundStockReceipt partial = new InboundStockReceipt();
partial.setAllocationId(fixture.allocation.getId()); partial.setOrderId(fixture.order.getId());
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(partial));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.stockIn("100", fixture.plan, fixture.task,
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
List.of(fixture.pallet, secondPallet), LocalDateTime.now()));
assertEquals(409, exception.getStatusCode().value());
verify(inventory, never()).stockIn(any(), any(), any());
verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any());
}
@Test
void activatesDropshipAfterGenericStockInReceiptIsCreated() {
Fixture fixture = fixture(5, 5, 5, 10);
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(new InboundStockReceipt()));
fixture.order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
verify(inventory, never()).stockIn(any(), any(), any());
verify(receipts, never()).insert(any(InboundStockReceipt.class));
ArgumentCaptor<List<InboundStockReceipt>> activationReceipts = ArgumentCaptor.forClass(List.class);
verify(dropshipOrders).activateAfterStockIn(eq("100"), eq(101L), eq(2L), activationReceipts.capture());
assertEquals(1, activationReceipts.getValue().size());
assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId());
}
private Fixture fixture(int required, int planned, int picked, int quantity) {
@@ -93,7 +93,7 @@ class InventorySchemaContractTest {
}
@Test
void entitiesExposeInboundOrderPackageDimensionOnly() throws NoSuchFieldException {
void entitiesExposeInboundOrderPackageDimensionAndLegacyQuantityPlaceholders() throws NoSuchFieldException {
assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId"));
assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId"));
@@ -103,7 +103,9 @@ class InventorySchemaContractTest {
assertThrowsNoField(InventoryPosition.class, "productQuantity");
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
assertThrowsNoField(InventoryLedger.class, "deltaQuantity");
assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity"));
}
private static void assertThrowsNoField(Class<?> type, String fieldName) {
@@ -76,6 +76,17 @@ class InventoryCommandServiceTest {
verify(operations).updateById(any(InventoryOperation.class));
}
@Test
void stockInAllowsPalletPositionWithZeroOptionalPackages() {
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0));
assertEquals(0, result.availablePackages());
verify(positions).insert(any(InventoryPosition.class));
verify(ledgers).insert(any(InventoryLedger.class));
}
@Test
void holdRejectsWhenAvailablePackagesAreInsufficient() {
InventoryBalance balance = balance(200L, 5);
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.procurement;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OverseasProcurementInventoryBoundaryTest {
@Test
void procurementRemainsABusinessDocumentAndDoesNotMoveAvailableInventory() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java"),
StandardCharsets.UTF_8);
assertTrue(source.contains("PENDING_REVIEW"));
assertFalse(source.contains("InventoryCommandService"));
assertFalse(source.contains("stockIn("));
assertFalse(source.contains("setAvailablePackages"));
assertFalse(source.contains("availablePackages"));
}
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.schema;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SchemaChineseCommentContractTest {
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
private static final Pattern TABLE = Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)"
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;");
@Test
void allLastlegTablesAndColumnsHaveChineseComments() throws Exception {
String baseline = Files.readString(
Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8);
String incremental = Files.readString(
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<TableDefinition> baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55);
assertAllChineseComments(incremental, "增量 SQL", 49);
assertCommentMigrationCovers(incremental, baselineTables);
}
@Test
void newlyAddedColumnsCarryChineseCommentsInBaselineAndIncrementalSql() throws Exception {
String baseline = Files.readString(
Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8);
String incremental = Files.readString(
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<String> columns = List.of(
"allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key",
"outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count",
"allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key",
"receipt_request_hash"
);
for (String column : columns) {
assertColumnHasChineseComment(baseline, column, "基线 SQL");
assertColumnHasChineseComment(incremental, column, "增量 SQL");
assertIncrementalAddColumnHasChineseComment(incremental, column);
}
}
private static List<TableDefinition> assertAllChineseComments(String sql, String source, int expectedCount) {
List<TableDefinition> tables = new ArrayList<>();
Matcher matcher = TABLE.matcher(sql);
while (matcher.find()) {
String table = matcher.group(1);
assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
List<String> columns = splitTopLevel(matcher.group(2));
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
List<String> columnNames = new ArrayList<>();
for (String definition : columns) {
if (isConstraint(definition)) {
continue;
}
String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", "");
columnNames.add(column);
assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'")
.matcher(definition).find(),
source + " 字段备注必须包含中文: " + table + "." + column);
}
tables.add(new TableDefinition(table, columnNames));
}
assertTrue(tables.size() >= expectedCount,
source + " 解析到的尾程表数量不足,实际=" + tables.size() + ",期望至少=" + expectedCount);
return tables;
}
private static void assertCommentMigrationCovers(String incremental, List<TableDefinition> tables) {
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移");
String migration = incremental.substring(marker);
for (TableDefinition table : tables) {
assertTrue(migration.contains("table_name='" + table.name() + "'"),
"025 备注迁移缺少表: " + table.name());
for (String column : table.columns()) {
assertTrue(migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'"),
"025 备注迁移缺少字段: " + table.name() + "." + column);
}
}
}
private static boolean isConstraint(String definition) {
String normalized = definition.stripLeading().toUpperCase();
return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ")
|| normalized.startsWith("KEY ") || normalized.startsWith("CONSTRAINT ")
|| normalized.startsWith("FOREIGN ") || normalized.startsWith("CHECK ");
}
private static void assertColumnHasChineseComment(String sql, String column, String source) {
Pattern columnPattern = Pattern.compile(
"(?is)\\b" + Pattern.quote(column) + "\\b[^,;]*?COMMENT\\s+''?[^',;]*[\\u4e00-\\u9fff][^',;]*''?");
assertTrue(columnPattern.matcher(sql).find(), source + " 新增字段缺少中文备注: " + column);
}
private static void assertIncrementalAddColumnHasChineseComment(String sql, String column) {
Pattern addColumnPattern = Pattern.compile(
"(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column)
+ "\\b[^;]*?COMMENT\\s+''[^';]*[\\u4e00-\\u9fff][^';]*''");
assertTrue(addColumnPattern.matcher(sql).find(), "增量 SQL 的 ADD COLUMN 缺少中文备注: " + column);
}
private static List<String> splitTopLevel(String body) {
List<String> definitions = new ArrayList<>();
int start = 0;
int parentheses = 0;
boolean quoted = false;
for (int i = 0; i < body.length(); i++) {
char current = body.charAt(i);
if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) {
quoted = !quoted;
} else if (!quoted && current == '(') {
parentheses++;
} else if (!quoted && current == ')') {
parentheses--;
} else if (!quoted && parentheses == 0 && current == ',') {
definitions.add(body.substring(start, i).trim());
start = i + 1;
}
}
definitions.add(body.substring(start).trim());
return definitions.stream().filter(value -> !value.isEmpty()).toList();
}
private record TableDefinition(String name, List<String> columns) {
}
}
@@ -11,11 +11,15 @@ class WarehouseTransferSchemaContractTest {
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception {
String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order", "ll_warehouse_transfer_item"}) {
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order",
"ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation",
"ll_warehouse_transfer_receipt"}) {
assertTrue(baseline.contains("create table if not exists " + table));
assertTrue(migration.contains("create table if not exists " + table));
}
assertTrue(migration.contains("information_schema.statistics"));
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
assertTrue(migration.contains("uk_ll_wh_transfer_active_position"));
assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source"));
}
}
@@ -4,6 +4,11 @@ import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
@@ -13,10 +18,13 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
@@ -29,17 +37,23 @@ import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -47,6 +61,8 @@ class WarehouseTransferServiceTest {
@Mock WarehouseTransferOrderMapper transfers;
@Mock WarehouseTransferItemMapper transferItems;
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
@Mock WarehouseTransferReceiptMapper transferReceipts;
@Mock InboundSnapshotMapper inboundSnapshots;
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@@ -55,6 +71,8 @@ class WarehouseTransferServiceTest {
@Mock RelabelItemMapper relabelItems;
@Mock RelabelStatusHistoryMapper relabelHistories;
@Mock LastlegOrderNoGenerator orderNoGenerator;
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
private WarehouseTransferService service;
@BeforeEach
@@ -63,6 +81,8 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "transfers", transfers);
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
@@ -71,8 +91,12 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(orderNoGenerator.next()).thenReturn("LMO-TRANSFER-1");
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
}
@Test
@@ -92,6 +116,7 @@ class WarehouseTransferServiceTest {
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo());
assertNull(tailOrder.getValue().getTransferWarehouseId());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
verify(relabelApplications).insert(any(RelabelApplication.class));
verify(relabelItems).insert(any(RelabelItem.class));
@@ -139,6 +164,126 @@ class WarehouseTransferServiceTest {
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
verifyNoInteractions(inventory);
}
@Test
void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() {
WarehouseTransferOrder transfer = transfer("APPROVED");
LastlegOrder tailOrder = tailOrder();
WarehouseTransferSourceOrder source = sourceOrder();
InventoryPosition position = position(501L, 601L, 701L, 5L);
InventoryBalance balance = balance(601L, 101L, "IN-1");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(sourceOrders.selectList(any())).thenReturn(List.of(source));
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position);
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
var result = service.allocate("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Allocate("ALLOC-1",
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5))));
ArgumentCaptor<WarehouseTransferInventoryAllocation> captured =
ArgumentCaptor.forClass(WarehouseTransferInventoryAllocation.class);
verify(inventoryAllocations).insert(captured.capture());
assertEquals(101L, captured.getValue().getInboundOrderId());
assertEquals(701L, captured.getValue().getPalletId());
assertEquals(5, captured.getValue().getPackageCount());
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("READY_TO_OUTBOUND", result.orderStatus());
verifyNoInteractions(inventory);
}
@Test
void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
LastlegOrder tailOrder = tailOrder();
WarehouseTransferInventoryAllocation allocation = allocation();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
var ordered = inOrder(inventory);
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move> move =
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move.class);
ordered.verify(inventory).allocate(eq("TENANT-A"), eq("USER-1"), move.capture());
ordered.verify(inventory).pick(eq("TENANT-A"), eq("USER-1"), move.capture());
ordered.verify(inventory).ship(eq("TENANT-A"), eq("USER-1"), move.capture());
assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5));
assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus());
assertEquals("SHIPPED", allocation.getStatus());
clearInvocations(inventory);
service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
verifyNoInteractions(inventory);
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(null);
when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn(
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn> stockIn =
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
assertEquals(5, stockIn.getValue().packages());
assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus());
assertEquals("RECEIVED", allocation.getStatus());
ArgumentCaptor<com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt> receipt =
ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class);
verify(transferReceipts).insert(receipt.capture());
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(receipt.getValue());
clearInvocations(inventory);
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
verifyNoInteractions(inventory);
}
@Test
void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
WarehouseTransferInventoryAllocation allocation = allocation();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "insufficient"))
.when(inventory).pick(eq("TENANT-A"), eq("USER-1"), any());
assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Outbound("OUT-1", null)));
verify(inventory, never()).ship(eq("TENANT-A"), eq("USER-1"), any());
verify(inventoryAllocations, never()).updateById(any(WarehouseTransferInventoryAllocation.class));
verify(transfers, never()).updateById(transfer);
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
assertEquals("RESERVED", allocation.getStatus());
}
@Test
void allocationRejectsPositionOutsideSelectedInboundOrder() {
WarehouseTransferOrder transfer = transfer("APPROVED");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder()));
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position(501L, 601L, 701L, 5L));
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 999L, "OTHER"));
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.allocate("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Allocate("ALLOC-1",
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5)))));
assertEquals(409, exception.getStatusCode().value());
verify(inventoryAllocations, never()).insert(any(WarehouseTransferInventoryAllocation.class));
verifyNoInteractions(inventory);
}
@Test
@@ -156,7 +301,7 @@ class WarehouseTransferServiceTest {
private static WarehouseTransferRequests.Create request(boolean needRelabel) {
return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8",
"123 FBA Road", 5, "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1",
"123 FBA Road", 5, new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1",
"TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
}
@@ -174,4 +319,52 @@ class WarehouseTransferServiceTest {
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5);
return transfer;
}
private static WarehouseTransferOrder transfer(String status) {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setTransferNo("WTR-1"); transfer.setStatus(status); transfer.setPackageCount(5);
return transfer;
}
private static LastlegOrder tailOrder() {
LastlegOrder order = historicalOrder();
order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT");
return order;
}
private static WarehouseTransferSourceOrder sourceOrder() {
WarehouseTransferSourceOrder source = new WarehouseTransferSourceOrder();
source.setId(41L); source.setTransferOrderId(21L); source.setCustomerId("TENANT-A");
source.setInboundOrderId(101L); source.setInboundCode("IN-1");
return source;
}
private static InventoryPosition position(long id, long balanceId, long palletId, long available) {
InventoryPosition position = new InventoryPosition();
position.setId(id); position.setCustomerId("TENANT-A"); position.setBalanceId(balanceId);
position.setPalletId(palletId); position.setPalletNo("PALLET-1"); position.setAvailablePackages(available);
return position;
}
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
InventoryBalance balance = new InventoryBalance();
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
balance.setInboundCode(inboundCode);
return balance;
}
private static WarehouseTransferInventoryAllocation allocation() {
WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation();
allocation.setId(51L); allocation.setTransferOrderId(21L); allocation.setCustomerId("TENANT-A");
allocation.setInventoryPositionId(501L); allocation.setInventoryBalanceId(601L);
allocation.setInboundOrderId(101L); allocation.setInboundCode("IN-1"); allocation.setPalletId(701L);
allocation.setPalletNo("PALLET-1"); allocation.setPackageCount(5); allocation.setStatus("RESERVED");
return allocation;
}
private static WarehouseTransferRequests.Receipt receiptRequest() {
return new WarehouseTransferRequests.Receipt(501L, "WH-DEST", "WH-D", "Destination warehouse",
"FBA-ID", "ONT8", "Amazon ONT8", 801L, "PALLET-DEST", "WTR-1", 901L,
"LOC-DEST", "RECEIVE-1", "received");
}
}
@@ -15,6 +15,10 @@ class UsTruckOrderSchemaContractTest {
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet"));
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1"));
assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0"));
assertTrue(sql.contains("receipt_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
}
@@ -2,8 +2,10 @@ package com.baiyue.lastleg.trucking.service;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
@@ -17,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -30,6 +33,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UsTruckOrderServiceTest {
@Mock UsTruckOrderMapper orders;
@Mock UsTruckOrderPalletMapper pallets;
@Mock UsTruckOrderStatusHistoryMapper histories;
private UsTruckOrderService service;
@@ -37,8 +41,10 @@ class UsTruckOrderServiceTest {
void setUp() {
service = new UsTruckOrderService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of());
}
@Test
@@ -63,9 +69,9 @@ class UsTruckOrderServiceTest {
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var result = service.changeStatus("100", "TK1", "7",
new UsTruckOrderRequests.ChangeStatus(0L, "PENDING_DISPATCH", "accepted"));
new UsTruckOrderRequests.ChangeStatus(0L, "WAITING_INBOUND", "accepted"));
assertEquals(UsTruckOrderService.PENDING_DISPATCH, result.summary().status());
assertEquals(UsTruckOrderService.WAITING_INBOUND, result.summary().status());
assertEquals(1, result.summary().version());
verify(histories).insert(any(UsTruckOrderStatusHistory.class));
}
@@ -78,6 +84,47 @@ class UsTruckOrderServiceTest {
assertEquals(409, error.getStatusCode().value());
}
@Test
void detailIncludesBoundPallets() {
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L);
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
pallet.setId(10L); pallet.setPalletNo("PLT-001"); pallet.setSequenceNo(1);
pallet.setSource("SYSTEM"); pallet.setStatus("OCCUPIED");
pallet.setWarehousePalletId(20L); pallet.setPackageCount(12);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
when(pallets.listOrder("100", 1L)).thenReturn(List.of(pallet));
var detail = service.detail("100", "TK1");
assertEquals(1, detail.pallets().size());
assertEquals("PLT-001", detail.pallets().get(0).palletNo());
assertEquals(12, detail.pallets().get(0).packageCount());
}
@Test
void receivingMorePalletsCreatesOverageAndKeepsDeclaredCount() {
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 0L);
order.setPalletCount(1);
when(orders.lockNo("100", "TK1")).thenReturn(order);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
List<UsTruckOrderPallet> savedPallets = new ArrayList<>();
when(pallets.listOrder("100", 1L)).thenAnswer(call -> List.copyOf(savedPallets));
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
savedPallets.add(call.getArgument(0));
return 1;
});
var result = service.receivePallets("100", "TK1", "7",
new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets"));
assertEquals(1, result.summary().palletCount());
assertEquals(3, result.summary().receivedPalletCount());
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status());
verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class));
}
private UsTruckOrderRequests.Create create(String key) {
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA",
"Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"),
@@ -87,7 +134,9 @@ class UsTruckOrderServiceTest {
private UsTruckOrder order(String status, long version) {
UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1");
order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setContainerType("40HQ"); order.setPackageCount(1); order.setStatus(status); order.setVersion(version);
order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0);
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
order.setStatus(status); order.setVersion(version);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order;
}
}
+2410 -280
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -15,7 +15,7 @@
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库 | `src/main/resources/db/warehouse.sql` |
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/warehouse/` |
## 数据与安全规则
@@ -24,8 +24,22 @@
- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。
- 内部资源接口必须校验服务令牌;普通用户不得直接调用。
## 数据库表索引与注释约束
| 业务域 | 表名 | 中文含义 |
|---|---|---|
| 库位 | `wh_location` | 海外仓库位资源 |
| 托盘 | `wh_pallet` | 海外仓实物托盘资源 |
| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 |
| 仓库 | `wh_warehouse` | 海外仓档案 |
| 仓库地址 | `wh_warehouse_address` | 海外仓地址 |
| 服务能力 | `wh_service_capability` | 海外仓服务能力 |
| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 |
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。
- `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。
## 修改与验证
- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。
- 定向验证:`mvn -pl warehouse -am test`。
+18 -18
View File
@@ -48,25 +48,25 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
CREATE TABLE IF NOT EXISTS wh_resource_lock (
id BIGINT UNSIGNED NOT NULL COMMENT 'Snowflake resource lock ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning tenant ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning warehouse ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved pallet ID',
location_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved location ID',
business_type VARCHAR(50) NOT NULL COMMENT 'Calling business type',
business_id VARCHAR(100) NOT NULL COMMENT 'Calling business identifier',
reservation_key VARCHAR(160) NOT NULL COMMENT 'Caller supplied idempotency key',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE, RELEASED or SCRAPPED',
locked_at DATETIME(3) NOT NULL COMMENT 'Reservation time',
released_at DATETIME(3) DEFAULT NULL COMMENT 'Release or scrap time',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
business_type VARCHAR(50) NOT NULL COMMENT '业务类型',
business_id VARCHAR(100) NOT NULL COMMENT '业务记录ID',
reservation_key VARCHAR(160) NOT NULL COMMENT '资源预留幂等键',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
released_at DATETIME(3) DEFAULT NULL COMMENT '释放时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key),
KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status),
KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status),
KEY idx_resource_lock_business (customer_id, business_type, business_id)
) ENGINE=InnoDB COMMENT='Atomic pallet and location reservation records';
) ENGINE=InnoDB COMMENT='仓储资源锁定记录';
CREATE TABLE IF NOT EXISTS wh_warehouse (
id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
@@ -160,12 +160,12 @@ ALTER TABLE wh_location
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
id BIGINT NOT NULL, customer_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL,
business_type VARCHAR(32) NOT NULL, business_id VARCHAR(128) NOT NULL,
pallet_id BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT NOT NULL COMMENT '主键ID', customer_id BIGINT NOT NULL COMMENT '客户租户ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID',
pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id),
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
) ENGINE=InnoDB COMMENT='Customer business binding to real warehouse pallet';
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
-- Every warehouse has one non-occupiable virtual location used for inventory history.
INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type,
@@ -0,0 +1,98 @@
package com.baiyue.warehouse.schema;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SchemaChineseCommentContractTest {
private static final Pattern TABLE = Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(wh_[a-z0-9_]+)"
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;");
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
@Test
void allWarehouseTablesAndColumnsHaveChineseCommentsAndMigrationCoverage() throws Exception {
String baseline = Files.readString(
Path.of("src", "main", "resources", "db", "warehouse.sql"), StandardCharsets.UTF_8);
String incremental = Files.readString(
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<TableDefinition> tables = parseAndAssert(baseline, "仓储基线 SQL");
assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
parseAndAssert(incremental, "统一增量 SQL");
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
assertTrue(marker >= 0, "统一增量 SQL 缺少 025 中文备注迁移");
String migration = incremental.substring(marker);
for (TableDefinition table : tables) {
assertTrue(migration.contains("table_name='" + table.name() + "'"),
"025 备注迁移缺少表: " + table.name());
for (String column : table.columns()) {
assertTrue(migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'"),
"025 备注迁移缺少字段: " + table.name() + "." + column);
}
}
}
private static List<TableDefinition> parseAndAssert(String sql, String source) {
List<TableDefinition> tables = new ArrayList<>();
Matcher matcher = TABLE.matcher(sql);
while (matcher.find()) {
String table = matcher.group(1);
assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
List<String> columns = new ArrayList<>();
for (String definition : splitTopLevel(matcher.group(2))) {
String normalized = definition.stripLeading().toUpperCase();
if (normalized.matches("^(PRIMARY|UNIQUE|KEY|CONSTRAINT|FOREIGN|CHECK)\\b.*")) {
continue;
}
String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", "");
columns.add(column);
assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'")
.matcher(definition).find(),
source + " 字段备注必须包含中文: " + table + "." + column);
}
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
tables.add(new TableDefinition(table, columns));
}
assertFalse(tables.isEmpty(), source + " 未解析到仓储表");
return tables;
}
private static List<String> splitTopLevel(String body) {
List<String> definitions = new ArrayList<>();
int start = 0;
int parentheses = 0;
boolean quoted = false;
for (int i = 0; i < body.length(); i++) {
char current = body.charAt(i);
if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) {
quoted = !quoted;
} else if (!quoted && current == '(') {
parentheses++;
} else if (!quoted && current == ')') {
parentheses--;
} else if (!quoted && parentheses == 0 && current == ',') {
definitions.add(body.substring(start, i).trim());
start = i + 1;
}
}
definitions.add(body.substring(start).trim());
return definitions.stream().filter(value -> !value.isEmpty()).toList();
}
private record TableDefinition(String name, List<String> columns) {
}
}