Merge branch 'test' into dev

This commit is contained in:
2026-09-14 17:04:24 +08:00
118 changed files with 2210 additions and 614 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -75,6 +75,10 @@ public interface InboundPlanOrderMapper {
String findSourceContainerBookingId(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findOrderType(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
@@ -130,10 +130,23 @@ public class InboundPlanService {
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
requiredReceivingWarehouse(containerWarehouseId);
validateOrders(snapshots);
int minimumPalletCount = suggestedPalletCount(order);
int sourcePalletCount = Objects.requireNonNullElse(
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
minimumPalletCount = Math.max(minimumPalletCount, sourcePalletCount);
String orderType = trimToNull(orderMapper.findOrderType(customerId, request.orderId()));
int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipOrders.palletSnapshots(customerId, request.orderId()).size()
: Objects.requireNonNullElse(
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
if (sourcePalletCount <= 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"一件代发订单缺少不可变来源托盘快照,不能生成入库计划");
}
if (request.palletCount() != sourcePalletCount) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"一件代发人工计划托数必须与头程封托托盘数一致:" + sourcePalletCount);
}
}
int minimumPalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
? sourcePalletCount : Math.max(suggestedPalletCount(order), sourcePalletCount);
if (request.palletCount() < minimumPalletCount) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
@@ -272,6 +285,10 @@ public class InboundPlanService {
int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(),
Objects.requireNonNullElse(suggested, 0));
int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount);
if (planned && "ONE_CLICK_DROPSHIP".equals(row.orderType())
&& targetPalletCount != sourcePalletCount) {
blockers.add("DROPSHIP_PALLET_COUNT_MISMATCH");
}
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
String destinationType = destinationType(row);
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
@@ -71,8 +71,8 @@ public class ReturnInventoryService {
long total = lines.stream().mapToLong(ReceiptLine::quantity).sum();
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo()); carton.setCartonMark(cartonMark);
carton.setSourceReturnNo(order.getReturnNo());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
@@ -253,9 +253,9 @@ public class ReturnInventoryService {
}
Map<String, Long> remaining = new LinkedHashMap<>(requested);
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
LinkedHashSet<String> consumedBatchNos = new LinkedHashSet<>();
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
@@ -87,11 +87,11 @@ public class LastlegOrderService {
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<String, String> containerNodes = containerNodes(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -581,7 +581,7 @@ public class LastlegOrderService {
return result;
}
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
private Map<Long, String> dispatchStatuses(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
List<LastlegOrderMapper.DispatchStatusRow> rows = orderMapper.findDispatchStatuses(
orders.stream().map(LastlegOrder::getId).toList());
@@ -591,14 +591,13 @@ public class LastlegOrderService {
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations,
String containerNode, String dispatchStatus) {
String containerNode, String dispatchStatus) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(),
containerNode,
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
@@ -234,6 +234,8 @@ public class LoadingCompletedConsumer {
String destinationType = destinationType(payload, source);
String customerId = owner.customerId();
String customerName = owner.customerName();
List<FirstLegLoadingPlanCompletedV1.Item> items = "ONE_CLICK_DROPSHIP".equals(orderType)
? canonicalItems(source.items()) : source.items();
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets(source, rawSource) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
@@ -259,7 +261,7 @@ public class LoadingCompletedConsumer {
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt()));
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
order.setVolumeWeightKg(items.stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId());
@@ -293,7 +295,7 @@ public class LoadingCompletedConsumer {
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
}
}
for(var item:source.items()) {
for(var item:items) {
LastlegOrderDetail detail=new LastlegOrderDetail();
detail.setCustomerId(customerId);
detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode());
@@ -377,11 +379,8 @@ public class LoadingCompletedConsumer {
private Map<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
Map<String, FirstLegLoadingPlanCompletedV1.Item> itemsByDetail = new java.util.LinkedHashMap<>();
for (var item : source.items()) {
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()
|| itemsByDetail.putIfAbsent(item.sourceInboundDetailId(), item) != null) {
throw new IllegalArgumentException("一件代发来源明细标识缺失或重复");
}
for (var item : canonicalItems(source.items())) {
itemsByDetail.put(item.sourceInboundDetailId(), item);
}
Map<String, List<String>> marksByDetail = new java.util.LinkedHashMap<>();
for (JsonNode pallet : rawSource.path("pallets")) {
@@ -395,7 +394,7 @@ public class LoadingCompletedConsumer {
sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
}
}
for (var item : source.items()) {
for (var item : itemsByDetail.values()) {
if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) {
throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致");
}
@@ -403,6 +402,51 @@ public class LoadingCompletedConsumer {
return marksByDetail;
}
/** 接受同一来源明细因多托装柜形成的多个分片,并在尾程落库前合并为唯一明细。 */
private List<FirstLegLoadingPlanCompletedV1.Item> canonicalItems(List<FirstLegLoadingPlanCompletedV1.Item> source) {
Map<String, List<FirstLegLoadingPlanCompletedV1.Item>> byDetail = new java.util.LinkedHashMap<>();
for (var item : source == null ? List.<FirstLegLoadingPlanCompletedV1.Item>of() : source) {
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) {
throw new IllegalArgumentException("一件代发来源明细标识缺失");
}
byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item);
}
List<FirstLegLoadingPlanCompletedV1.Item> result = new ArrayList<>();
for (List<FirstLegLoadingPlanCompletedV1.Item> fragments : byDetail.values()) {
var first = fragments.get(0);
if (fragments.stream().anyMatch(item -> !Objects.equals(first.skuCode(), item.skuCode())
|| !Objects.equals(first.destinationWarehouseId(), item.destinationWarehouseId())
|| !Objects.equals(first.destinationWarehouseCode(), item.destinationWarehouseCode()))) {
throw new IllegalArgumentException("同一来源明细的尾程事件分片快照不一致");
}
List<String> marks = fragments.stream().flatMap(item -> item.cartonMarks().stream()).toList();
result.add(new FirstLegLoadingPlanCompletedV1.Item(first.sourceInboundDetailId(), first.skuCode(),
first.productNameZh(), first.productNameEn(),
fragments.stream().mapToInt(FirstLegLoadingPlanCompletedV1.Item::packageCount).sum(),
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::productQuantity),
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::palletCount),
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::grossWeightKg),
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::netWeightKg),
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg),
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::volumeCbm),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.fbaTrackingNo(), first.shipmentNo(), first.shippingMark(), first.palletNo(), marks));
}
return List.copyOf(result);
}
private static Integer sumNullableInt(List<FirstLegLoadingPlanCompletedV1.Item> values,
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, Integer> getter) {
List<Integer> present = values.stream().map(getter).filter(Objects::nonNull).toList();
return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum();
}
private static BigDecimal sumDecimal(List<FirstLegLoadingPlanCompletedV1.Item> values,
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, BigDecimal> getter) {
List<BigDecimal> present = values.stream().map(getter).filter(Objects::nonNull).toList();
return present.isEmpty() ? null : present.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
}
private void createCartonLabels(String customerId, Long orderId, String destinationType,
FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) {
if (!"OVERSEAS".equals(destinationType)) return;
@@ -52,6 +52,7 @@ public final class WarehouseTransferRequests {
}
}
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
@@ -358,6 +358,9 @@ public class WarehouseTransferService {
case "REJECT", REJECTED -> REJECTED;
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
if (REJECTED.equals(next) && !StringUtils.hasText(request.remark())) {
throw badRequest("驳回原因不能为空");
}
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!PENDING_REVIEW.equals(transfer.getStatus())) {
@@ -384,7 +387,8 @@ public class WarehouseTransferService {
order.setUpdatedAt(now); orders.updateById(order);
if (!java.util.Objects.equals(before, orderStatus)) {
insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo,
"Warehouse transfer reviewed: " + next, now);
"Warehouse transfer reviewed: " + next
+ (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now);
}
return detail(customerId, transferNo);
}
@@ -1708,6 +1708,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_relabel_item (
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
id BIGINT NOT NULL COMMENT '主键ID',
application_id BIGINT NOT NULL COMMENT '申请单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -2232,6 +2235,7 @@ ALTER TABLE ll_security_projection
-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN');
SET @platform_admin_overseas_authorities = JSON_ARRAY(
'platform:overseas-warehouse:warehouse-transfer:approve',
'platform:finance:bill:adjust',
'platform:finance:bill:update',
'platform:finance:bill:void',
@@ -2488,6 +2492,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
@@ -233,11 +233,9 @@ class DispatchPdaWorkflowServiceTest {
order.setVehiclePlate("CA-100");
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
DispatchItem item = item(21L, "100", 6);
DispatchPdaPick pick = new DispatchPdaPick();
pick.setPackages(6L);
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
OutboundOrder outbound = new OutboundOrder();
outbound.setOutboundNo("OB-1");
outbound.setStatus("WAIT_OUTBOUND");
outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND");
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
@@ -245,17 +243,17 @@ class DispatchPdaWorkflowServiceTest {
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7",
new DispatchPdaRequests.Depart("depart-success", null));
when(palletLocks.selectList(any())).thenReturn(List.of());
DispatchPdaResponses.Departed result = service.depart(
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null));
assertEquals("DEPARTED", result.status());
assertEquals("DEPARTED", order.getStatus());
assertTrue(order.getDepartAt() != null);
assertEquals("OUTBOUNDED", outbound.getStatus());
when(palletLocks.selectList(any())).thenReturn(List.of());
assertNotNull(order.getDepartAt());
assertEquals(order.getDepartAt(), order.getUpdatedAt());
verify(orders).updateById(order);
assertEquals("DEPARTED", order.getStatus());
assertEquals("OUTBOUNDED", outbound.getStatus());
}
@Test
@@ -120,6 +120,25 @@ class ContainerInboundPlanPreviewTest {
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
}
@Test
void blocksExistingDropshipPlanWhosePalletCountDiffersFromFrozenSourcePallets() {
when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP-MISMATCH")).thenReturn(List.of(
row(1L, "LM-DROPSHIP", "ONE_CLICK_DROPSHIP", "OVERSEAS", "US-LAX-01", "洛杉矶仓", 2,
"IP-DROPSHIP", 2, 1, "PENDING_ASSIGNMENT", 0)
));
when(dropshipOrders.palletSnapshots("100", 1L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10, List.of())));
var result = service.previewContainer("BOOKING-DROPSHIP-MISMATCH");
var order = result.groups().get(0).orders().get(0);
assertFalse(result.ready());
assertEquals("BLOCKED", order.readiness());
assertEquals(List.of("DROPSHIP_PALLET_COUNT_MISMATCH"), order.blockers());
assertEquals(1, order.sourcePalletCount());
assertEquals(2, order.targetPalletCount());
}
@Test
void exposesHeadLegExistingSupplementAndFinalPalletCountsWithoutBlocking() {
when(orderMapper.containerPreviewOrders("BOOKING-SUPPLEMENT")).thenReturn(List.of(
@@ -183,6 +183,43 @@ class InboundPlanServiceTest {
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void oneClickDropshipManualPlanMustKeepTheFrozenSourcePalletCount() {
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
2, "WOOD", false, false, false, null), "dropship-pallet-mismatch", "900"));
assertEquals(400, error.getStatusCode().value());
assertEquals("一件代发人工计划托数必须与头程封托托盘数一致:1", error.getReason());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void oneClickDropshipUsesFrozenSourcePalletCountInsteadOfVolumeSuggestion() {
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(0), any()))
.thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 1, "WOOD", false, false, false, null),
"dropship-exact-pallet-count", "900");
assertEquals(1, result.palletCount());
verify(planMapper).insert(any(InboundPlan.class));
verify(palletMapper).insert(any(InboundPlanPallet.class));
}
@Test
void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() {
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");
@@ -12,6 +12,28 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class ReturnCartonQueryTest {
@Test
void baseMapperReplayColumnExistsInEveryReturnCartonBaseline() throws Exception {
var table = com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), "return-carton-contract"),
com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton.class);
assertThat(table.getAllSqlSelect()).contains("new_carton_mark");
for (var path : java.util.List.of(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"),
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) {
String sql = java.nio.file.Files.readString(path);
var matcher = java.util.regex.Pattern.compile(
"CREATE TABLE IF NOT EXISTS ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE")
.matcher(sql);
int definitions = 0;
while (matcher.find()) {
definitions++;
assertThat(matcher.group(1)).as(path.toString()).contains("new_carton_mark VARCHAR(255)");
}
assertThat(definitions).isPositive();
}
}
@Test
void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
@@ -147,6 +147,32 @@ class LoadingCompletedConsumerTest {
verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
verify(orders,never()).insert(any(LastlegOrder.class));
}
@Test void consumesOneDropshipSourceDetailSplitAcrossTwoPallets() {
EventEnvelope original=overseasDropshipEvent(false);
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
var order=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0);
var secondItem=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("items").get(0).deepCopy();
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("items")).add(secondItem);
order.put("packageCount",10).put("productQuantity",100).put("palletCount",2);
var secondPallet=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("pallets").get(0).deepCopy();
secondPallet.put("palletNo","DP-260914-0022");
var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)secondPallet.path("skuPackages").get(0);
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets")).add(secondPallet);
service.consume(rehash(original,node));
var detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class);
verify(details).insert(detailCaptor.capture());
assertEquals("d-1",detailCaptor.getValue().getSourceInboundDetailId());
assertEquals(10,detailCaptor.getValue().getPackageCount());
assertEquals(100,detailCaptor.getValue().getProductQuantity());
verify(cartons,times(10)).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
var received=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
verify(dropshipOrders).receive(received.capture());
assertEquals(List.of("DP-260909-0143","DP-260914-0022"),
received.getValue().pallets().stream().map(DropshipRequests.Pallet::palletNo).toList());
}
@Test void mapsSameSkuDifferentSourceDetailsWithoutGuessing() {
EventEnvelope original=overseasDropshipEvent(false);
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
@@ -222,7 +222,7 @@ class WarehouseTransferServiceTest {
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.review("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Review("APPROVE", "approved"));
new WarehouseTransferRequests.Review("APPROVE", null));
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
@@ -233,6 +233,41 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(inventory);
}
@Test
void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() {
for (String decision : List.of("REJECT", "REJECTED")) {
for (String reason : new String[] { null, "", " \t\n" }) {
var error = assertThrows(ResponseStatusException.class, () -> service.review(
"TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review(decision, reason)));
assertEquals(400, error.getStatusCode().value());
assertEquals("驳回原因不能为空", error.getReason());
}
}
verifyNoInteractions(transfers, orders, orderHistories, inventory);
}
@Test
void rejectionWithReasonCancelsLinkedOrderAndPreservesReasonInHistory() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.review("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Review("REJECT", " 地址不完整 "));
assertEquals("REJECTED", result.status());
assertEquals("CANCELLED", tailOrder.getCurrentStatus());
var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class);
verify(orderHistories).insert(history.capture());
assertTrue(history.getValue().getRemark().endsWith("; 地址不完整"));
verifyNoInteractions(inventory);
}
@Test
void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() {
WarehouseTransferOrder transfer = transfer("APPROVED");