fix(dropship): preserve carton quantities and shared pallet outbound ownership

This commit is contained in:
2026-09-14 20:55:09 +08:00
parent 6dbcb2128c
commit 4f13ee2ec3
24 changed files with 302 additions and 99 deletions
@@ -157,11 +157,20 @@ public record FirstLegLoadingPlanCompletedV2(
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks) {
List<String> cartonMarks, java.util.Map<String, Integer> cartonQuantities) {
public SkuPackageSnapshot {
if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) {
throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot");
}
if (cartonMarks == null || cartonQuantities == null || cartonMarks.size() != packageCount
|| new java.util.HashSet<>(cartonMarks).size() != packageCount
|| !cartonQuantities.keySet().equals(new java.util.HashSet<>(cartonMarks))
|| cartonQuantities.values().stream().anyMatch(quantity -> quantity == null || quantity <= 0)
|| cartonQuantities.values().stream().mapToLong(Integer::longValue).sum() != productQuantity) {
throw new IllegalArgumentException("cartonQuantities must exactly cover cartonMarks and productQuantity");
}
cartonMarks = List.copyOf(cartonMarks);
cartonQuantities = java.util.Map.copyOf(cartonQuantities);
}
}
}
@@ -66,7 +66,7 @@ components:
items: {$ref: '#/components/schemas/FirstLegSkuPackageSnapshotV2'}
FirstLegSkuPackageSnapshotV2:
type: object
required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks]
required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks, cartonQuantities]
properties:
sourceInboundDetailId: {type: string, minLength: 1, description: Exact source inbound detail identity matching the order item; never infer from SKU because multiple details may share a SKU}
skuCode: {type: string}
@@ -74,6 +74,10 @@ components:
packageCount: {type: integer, minimum: 1}
productQuantity: {type: integer, minimum: 1}
cartonMarks: {type: array, items: {type: string}, minItems: 1}
cartonQuantities:
type: object
description: 箱唛到逐箱实收件数的完整映射;键与 cartonMarks 相同,件数合计等于 productQuantity
additionalProperties: {type: integer, minimum: 1}
OverseasContainerUnloadedV1:
type: object
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
@@ -11,15 +11,29 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void exactCartonQuantitiesRejectMissingMarksAndIncorrectTotals() {
var marks = List.of("BOX-A", "BOX-B");
var snapshot = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 7, "BOX-B", 13));
assertThat(snapshot.cartonQuantities().get("BOX-B")).isEqualTo(13);
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 20))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 7, "BOX-B", 12))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 0, "BOX-B", 20))).isInstanceOf(IllegalArgumentException.class);
}
@Test
void palletSkuRequiresExplicitSourceDetailIncludingJsonDeserialization() throws Exception {
var mapper = new ObjectMapper();
var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"));
var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"));
var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10));
var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"), java.util.Map.of("BOX-102", 20));
assertThat(mapper.readValue(mapper.writeValueAsString(first), FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class))
.isEqualTo(first).isNotEqualTo(second);
for (String invalid : new String[]{null, "", " "}) {
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101")))
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("sourceInboundDetailId");
}
assertThatThrownBy(() -> mapper.readValue("{\"skuCode\":\"SAME\",\"packageCount\":1,\"productQuantity\":10,\"cartonMarks\":[\"BOX-101\"]}",
@@ -81,7 +95,7 @@ class FirstLegLoadingPlanCompletedV2Test {
@Test
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
"101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
"101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"), java.util.Map.of("BOX-001", 7, "BOX-002", 13));
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
@@ -1528,7 +1528,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id),
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code),
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
) ENGINE=InnoDB COMMENT='出库托盘明细';
@@ -1968,6 +1968,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
product_quantity BIGINT NOT NULL COMMENT '该箱实收商品件数',
carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号',
version BIGINT NOT NULL DEFAULT 0 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_ll_dropship_carton(customer_id,order_id,carton_mark),
@@ -293,61 +293,28 @@ public class LoadingCompletedOutboxService {
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source,
Map<String, PalletManifest> manifests) {
if (source.isEmpty()) return List.of();
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
return actualPalletSnapshots(values, source, manifests);
if (source.isEmpty() || source.stream().anyMatch(item -> !Boolean.TRUE.equals(item.getActualBinding()))) {
throw new BizException(ResultCode.CONFLICT, "一件代发必须提供真实逐箱实收托盘快照");
}
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(),
LinkedHashMap::new, java.util.stream.Collectors.toList()));
int loadedPackages = byPallet.values().stream().map(list -> list.get(0)).map(PalletRow::getAllocatedPackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
if (loadedPackages != sumInt(values, Row::getPackageCount)) {
throw new BizException(ResultCode.CONFLICT, "一键代发实装箱数与托盘已拣箱数不一致");
}
Map<Long, DetailSnapshot> details = new LinkedHashMap<>();
values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).forEach(row ->
details.putIfAbsent(row.getSourceInboundDetailId(), new DetailSnapshot(row.getSourceInboundDetailId(),
row.getSkuCode(), row.getProductNameZh(), row.getPackageCount(), row.getProductQuantity(), new ArrayList<>())));
source.stream().filter(item -> item.getBoxCode() != null).forEach(item -> {
DetailSnapshot detail = details.get(item.getSourceInboundDetailId());
if (detail != null && !detail.marks().contains(item.getBoxCode())) detail.marks().add(item.getBoxCode());
});
Map<Long, Integer> remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap(
DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new));
Map<Long, Integer> remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap(
DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new));
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> result = new ArrayList<>();
for (List<PalletRow> palletRows : byPallet.values()) {
PalletRow first = palletRows.get(0);
int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount();
List<FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot> sku = new ArrayList<>();
for (DetailSnapshot detail : details.values()) {
int remaining = remainingPackages.getOrDefault(detail.detailId(), 0);
if (capacity <= 0 || remaining <= 0) continue;
int count = Math.min(capacity, remaining);
int totalQuantity = detail.productQuantity() == null ? count
: (int) Math.round((double) detail.productQuantity() * count / Math.max(1, detail.packageCount()));
int markStart = remainingMarks.getOrDefault(detail.detailId(), 0);
int markEnd = Math.min(detail.marks().size(), markStart + count);
List<String> marks = new ArrayList<>(detail.marks().subList(markStart, markEnd));
if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致");
sku.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.detailId().toString(), detail.skuCode(),
detail.productName(), count, totalQuantity, marks));
remainingPackages.put(detail.detailId(), remaining - count);
remainingMarks.put(detail.detailId(), markEnd);
capacity -= count;
}
if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(
first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku));
}
if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) {
throw new BizException(ResultCode.CONFLICT, "一键代发托盘未覆盖全部实装货物明细");
}
return result;
return actualPalletSnapshots(values, source, manifests);
}
/** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */
private Map<String, Integer> cartonQuantities(List<PalletRow> rows, List<String> marks) {
Map<String, Integer> quantities = new LinkedHashMap<>();
for (PalletRow row : rows) {
if (!marks.contains(row.getBoxCode())) continue;
Integer quantity = row.getActualSkuQuantity();
if (quantity == null || quantity <= 0 || quantities.putIfAbsent(row.getBoxCode(), quantity) != null) {
throw new BizException(ResultCode.CONFLICT, "一件代发箱唛缺少有效的逐箱实点 SKU 数量");
}
}
if (quantities.size() != marks.size()) {
throw new BizException(ResultCode.CONFLICT, "一件代发逐箱实点 SKU 数量未覆盖全部箱唛");
}
return quantities;
}
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
List<Row> values, List<PalletRow> source, Map<String, PalletManifest> manifests) {
Map<Long, Row> details = values.stream().collect(java.util.stream.Collectors.toMap(
@@ -373,7 +340,7 @@ public class LoadingCompletedOutboxService {
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量");
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(),
detail.getProductNameZh(), marks.size(), quantity, marks));
detail.getProductNameZh(), marks.size(), quantity, marks, cartonQuantities(detailRows.getValue(), marks)));
actualPackages += marks.size();
}
PalletManifest manifest = manifests.get(pallet.getKey());
@@ -415,8 +382,6 @@ public class LoadingCompletedOutboxService {
return "ONE_CLICK_DROPSHIP".equals(value);
}
private record DetailSnapshot(Long detailId, String skuCode, String productName,
Integer packageCount, Integer productQuantity, List<String> marks) {}
private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {}
private void insert(EventEnvelope envelope) {
@@ -290,7 +290,7 @@ class LoadingCompletedOutboxServiceTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void freezesRepeatedSkuCartonsBySourceDetailForBothPalletSources(boolean actualBinding) throws Exception {
void freezesActualSkuCartonsAndRejectsInferredPalletSources(boolean actualBinding) throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setSkuCode("SAME-SKU"); first.setProductQuantity(10);
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
@@ -305,6 +305,10 @@ class LoadingCompletedOutboxServiceTest {
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"),
pallet(11,100,1,"DP-260909-0001",2,101,"SAME-SKU",1,10,"BOX-101")));
assertThatThrownBy(() -> service.publish(90L)).isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("真实逐箱实收");
verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
return;
}
service.publish(90L);
service.publish(90L);
@@ -320,6 +324,7 @@ class LoadingCompletedOutboxServiceTest {
assertThat(sku.path("cartonMarks")).hasSize(1);
assertThat(sku.path("cartonMarks").get(0).asText()).isEqualTo("BOX-" + detail);
assertThat(sku.path("productQuantity").asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
assertThat(sku.path("cartonQuantities").path("BOX-" + detail).asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
}
assertThat(frozen.get(0).path("sourceInboundDetailId"))
.isNotEqualTo(frozen.get(1).path("sourceInboundDetailId"));
@@ -332,10 +337,11 @@ class LoadingCompletedOutboxServiceTest {
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-001"),
pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-002"),
pallet(11,100,2,"PLT-02",1,102,"SKU-B",1,5,"BOX-003")));
when(snapshots.workPalletCount(90L)).thenReturn(2);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-001",7),
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-002",13),
actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-003",5)));
service.publish(90L);
@@ -346,6 +352,8 @@ class LoadingCompletedOutboxServiceTest {
assertThat(event.payload().at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(event.payload().at("/orders/0/pallets")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-001").asInt()).isEqualTo(7);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-002").asInt()).isEqualTo(13);
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@@ -463,6 +471,7 @@ class LoadingCompletedOutboxServiceTest {
String sku,String boxCode,int actualSkuQuantity) {
PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode);
row.setActualBinding(true);
row.setPalletId(Integer.toUnsignedLong(palletNo.hashCode()) + 1L);
row.setActualSkuQuantity(actualSkuQuantity);
return row;
}
@@ -265,13 +265,21 @@ public class DeliveryTransferService {
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
outboundOrders.insert(outbound);
Set<Long> physicalPalletIds = new HashSet<>();
for (DeliveryRequests.OneClickItem input : request.items()) {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
if (balance == null || !customerId.equals(balance.getCustomerId()) || !StringUtils.hasText(balance.getInboundCode())) {
throw conflict("一件代发出库库存缺少所属入仓码");
}
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
item.setInboundCode(balance.getInboundCode());
physicalPalletIds.add(position.getPalletId());
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
outboundPallets.insert(item);
}
outbound.setPalletCount(physicalPalletIds.size());
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
@@ -476,6 +484,11 @@ public class DeliveryTransferService {
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
throw conflict("Scanned inventory position does not belong to the bound warehouse");
}
InventoryBalance selectedBalance = inventoryBalances.selectById(selectedPosition.getBalanceId());
if (selectedBalance == null || !customerId.equals(selectedBalance.getCustomerId())
|| !StringUtils.hasText(item.getInboundCode()) || !item.getInboundCode().equals(selectedBalance.getInboundCode())) {
throw conflict("所选库存不属于出库明细的入仓码");
}
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
List<DropshipCarton> cartons = StringUtils.hasText(item.getCartonMarks())
@@ -488,7 +501,12 @@ public class DeliveryTransferService {
inventoryCommands.allocate(customerId, operatorId, move);
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":PICK", null));
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":SHIP", null));
if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages()) != 1) throw conflict("Insufficient one-click dropship SKU inventory");
long productQuantity = 0;
for (DropshipCarton carton : cartons) {
if (carton.getProductQuantity() == null || carton.getProductQuantity() <= 0) throw conflict("出库箱唛缺少逐箱实收件数");
productQuantity = Math.addExact(productQuantity, carton.getProductQuantity());
}
if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages(), productQuantity) != 1) throw conflict("Insufficient one-click dropship SKU inventory");
for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); }
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item);
@@ -630,6 +648,8 @@ public class DeliveryTransferService {
long available = value(position.getAvailablePackages());
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
if (StringUtils.hasText(item.getInboundCode()) && (balance == null
|| !item.getInboundCode().equals(balance.getInboundCode()))) continue;
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
List<String> marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId())
.stream().filter(carton -> "AVAILABLE".equals(carton.getStatus()))
@@ -698,10 +718,13 @@ public class DeliveryTransferService {
OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
String customerId = outbound.getCustomerId();
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
DeliveryOrder delivery = deliveryOrders.lockByOutbound(customerId, outbound.getId());
if (delivery == null && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) {
throw new IllegalStateException("Delivery document is missing for dispatch outbound order");
}
List<DeliveryResponses.OutboundPalletRow> pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream()
.map(this::outboundPalletRow).toList();
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), pallets);
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), delivery == null ? null : deliveryRow(delivery), pallets);
}
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
@@ -15,5 +15,6 @@ public final class DropshipRequests {
@NotBlank @Size(max = 64) String manifestHash) {}
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
@Positive long productQuantity,
@NotEmpty List<@NotBlank String> cartonMarks) {}
@NotEmpty List<@NotBlank String> cartonMarks,
@NotEmpty java.util.Map<@NotBlank String, @Positive Long> cartonQuantities) {}
}
@@ -14,6 +14,7 @@ public class DropshipCarton {
private String palletNo;
private String skuCode;
private String cartonMark;
private Long productQuantity;
private Integer cartonSequence;
private String status;
private String outboundOrderNo;
@@ -33,7 +33,7 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
"AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " +
"ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " +
"WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " +
"AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
"AND oi.inbound_code=b.inbound_code AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@@ -14,8 +14,8 @@ import java.util.List;
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code")
List<DropshipSkuInventory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages}")
int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},product_quantity=product_quantity-#{quantity},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages} AND product_quantity>=#{quantity}")
int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages, @Param("quantity") long quantity);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count+#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku}")
int restore(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
}
@@ -83,6 +83,7 @@ public class DropshipOrderService {
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
carton.setProductQuantity(input.cartonQuantities().get(mark));
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
ledger.insert(stockInFact(request, order, carton, now));
@@ -102,6 +103,12 @@ public class DropshipOrderService {
if (item.packageCount() != item.cartonMarks().size()) {
throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode());
}
if (item.cartonQuantities() == null
|| !item.cartonQuantities().keySet().equals(new java.util.HashSet<>(item.cartonMarks()))
|| item.cartonQuantities().values().stream().anyMatch(value -> value == null || value <= 0)
|| item.cartonQuantities().values().stream().mapToLong(Long::longValue).sum() != item.productQuantity()) {
throw conflict("逐箱实收件数必须完整且与 SKU 汇总件数一致");
}
if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode());
for (String mark : item.cartonMarks()) {
if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark);
@@ -507,18 +507,24 @@ public class LoadingCompletedConsumer {
JsonNode rawPallets = rawSource.path("pallets");
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
for (var sku : pallet.skuPackages()) {
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks()), (left, right) -> {
List<String> marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks());
return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(),
Math.addExact(left.packageCount(), right.packageCount()),
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
});
}
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
for (int skuIndex = 0; skuIndex < pallet.skuPackages().size(); skuIndex++) {
var sku = pallet.skuPackages().get(skuIndex);
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks(),
cartonQuantities(rawPallet.path("skuPackages").path(skuIndex))), (left, right) -> {
List<String> marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks());
Map<String, Long> quantities = new java.util.LinkedHashMap<>(left.cartonQuantities());
right.cartonQuantities().forEach((mark, quantity) -> {
if (quantities.putIfAbsent(mark, quantity) != null) throw new IllegalArgumentException("Duplicate carton quantity: " + mark);
});
return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(),
Math.addExact(left.packageCount(), right.packageCount()),
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks), Map.copyOf(quantities));
});
}
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
@@ -567,6 +573,19 @@ public class LoadingCompletedConsumer {
return value.toLowerCase(java.util.Locale.ROOT);
}
private Map<String, Long> cartonQuantities(JsonNode sku) {
JsonNode node = sku.path("cartonQuantities");
if (!node.isObject()) throw new IllegalArgumentException("One-click dropship cartonQuantities is required");
Map<String, Long> result = new java.util.LinkedHashMap<>();
node.fields().forEachRemaining(entry -> {
if (!entry.getValue().isIntegralNumber() || !entry.getValue().canConvertToLong() || entry.getValue().longValue() <= 0) {
throw new IllegalArgumentException("Invalid carton quantity: " + entry.getKey());
}
result.put(entry.getKey(), entry.getValue().longValue());
});
return Map.copyOf(result);
}
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
JsonNode nodes = source.path("skuPackages");
@@ -585,7 +604,7 @@ public class LoadingCompletedConsumer {
}
if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) {
String productName = item.path("productName").asText(item.path("productNameZh").asText(null));
result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks));
result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks, cartonQuantities(item)));
}
}
return result;
@@ -1435,7 +1435,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id),
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code),
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
) ENGINE=InnoDB COMMENT='出库托盘明细';
@@ -1877,6 +1877,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
product_quantity BIGINT NOT NULL COMMENT '该箱实收商品件数',
carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号',
version BIGINT NOT NULL DEFAULT 0 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_ll_dropship_carton(customer_id,order_id,carton_mark),
@@ -57,12 +57,16 @@ components:
items: { $ref: '#/components/schemas/DropshipSkuPackages' }
DropshipSkuPackages:
type: object
required: [skuCode, packageCount, productQuantity, cartonMarks]
required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities]
properties:
skuCode: { type: string, minLength: 1 }
productName: { type: string, nullable: true }
packageCount: { type: integer, format: int64, minimum: 1 }
productQuantity: { type: integer, format: int64, minimum: 1 }
cartonQuantities:
type: object
description: 箱唛到逐箱实收商品件数的完整映射,键必须与 cartonMarks 一致,值合计必须等于 productQuantity
additionalProperties: { type: integer, format: int64, minimum: 1 }
cartonMarks:
type: array
minItems: 1
@@ -664,7 +664,7 @@ paths:
description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: '出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } }
responses: { '200': { description: '出库单、出库托盘及关联派送单详情;一件代发等独立出库无派送单时 delivery 为 null,排车转派送必须存在派送单;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } }
/api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:
post:
summary: 取消未执行出库单
@@ -27,7 +27,7 @@ class DeliverySchemaContractTest {
assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)"));
assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item"));
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)"));
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code)"));
assertTrue(schema.contains("destination_warehouse_code varchar(128) null"));
assertTrue(schema.contains("order_refs varchar(2000) null"));
assertTrue(schema.contains("inbound_code varchar(128) null"));
@@ -279,6 +279,31 @@ class DeliveryTransferServiceTest {
verify(outboundOrders, never()).insert((OutboundOrder) any());
}
@Test
void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301");
outbound.setOutboundType("ONE_CLICK_DROPSHIP");
outbound.setStatus("OUTBOUNDED");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(
pallet(501L, "DP-260914-0001", "ONT8", "Ontario", "IN-A", 4)));
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
assertEquals("OUTBOUNDED", detail.outbound().status());
org.junit.jupiter.api.Assertions.assertNull(detail.delivery());
assertEquals("IN-A", detail.pallets().get(0).inboundCode());
assertEquals(4, detail.pallets().get(0).packageCount());
}
@Test
void dispatchOutboundStillRequiresItsDeliveryDocument() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
outbound.setOutboundType("DISPATCH_DELIVERY");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
assertThrows(IllegalStateException.class, () -> service.outboundDetail("OUT-301"));
}
@Test
void outboundDetailIncludesPalletCodesForDisplay() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
@@ -406,6 +431,33 @@ class DeliveryTransferServiceTest {
verify(outboundPallets, never()).findByOutbound(any(), any());
}
@Test
void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() {
for (long i = 1; i <= 2; i++) {
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 900L + i, "SKU-1"))
.thenReturn(dropshipCandidate(900L + i, 1000L + i, "WH-1", "DP-SHARED", "SKU-1", 1L));
when(dropshipCartons.lockAvailable("TENANT-A", 1000L + i, "SKU-1", 1L))
.thenReturn(List.of(new DropshipCarton()));
InventoryPosition position = new InventoryPosition();
position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i);
position.setPalletId(501L); position.setPalletNo("DP-SHARED");
InventoryBalance balance = new InventoryBalance();
balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-" + i);
when(inventoryPositions.selectById(900L + i)).thenReturn(position);
when(inventoryBalances.selectById(800L + i)).thenReturn(balance);
}
service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
new DeliveryRequests.OneClickItem(901L, 1L, "SKU-1"),
new DeliveryRequests.OneClickItem(902L, 1L, "SKU-1")), null, "shared-pallet"));
ArgumentCaptor<OutboundPalletItem> items = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(items.capture());
assertEquals(List.of("IN-1", "IN-2"), items.getAllValues().stream().map(OutboundPalletItem::getInboundCode).toList());
ArgumentCaptor<OutboundOrder> order = ArgumentCaptor.forClass(OutboundOrder.class);
verify(outboundOrders).updateById(order.capture());
assertEquals(1, order.getValue().getPalletCount());
assertEquals(2, order.getValue().getPackageCount());
}
@Test
void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() {
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L);
@@ -539,17 +591,26 @@ class DeliveryTransferServiceTest {
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A");
DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B");
InventoryPosition otherInbound = new InventoryPosition();
otherInbound.setId(902L); otherInbound.setCustomerId("TENANT-A"); otherInbound.setBalanceId(802L);
otherInbound.setOverseasWarehouseId("WH-1"); otherInbound.setPalletId(501L);
otherInbound.setAvailablePackages(2L);
InventoryBalance otherBalance = new InventoryBalance();
otherBalance.setId(802L); otherBalance.setCustomerId("TENANT-A"); otherBalance.setInboundCode("IN-B");
otherBalance.setSkuCode("SKU-1");
when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position));
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position, otherInbound));
when(inventoryBalances.selectById(801L)).thenReturn(balance);
when(inventoryBalances.selectById(802L)).thenReturn(otherBalance);
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second));
DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701");
assertEquals(2, detail.items().get(0).positions().size());
verify(dropshipProfiles, never()).findPosition("TENANT-A", 902L);
assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream()
.map(DeliveryResponses.PdaPickPosition::cartonMark).toList());
DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0);
@@ -588,6 +649,41 @@ class DeliveryTransferServiceTest {
assertEquals("CTN-A", detail.items().get(0).positions().get(0).cartonMark());
}
@Test
void dropshipPartialPicksDeductExactCartonProductQuantities() {
var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class);
ReflectionTestUtils.setField(service, "inventoryCommands", commands);
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "quantity-test");
outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("ONE_CLICK_DROPSHIP");
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2);
item.setSkuCode("SKU-1");
InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A");
position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1");
InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A");
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L);
DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L);
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(outboundPallets.selectById(item.getId())).thenReturn(item);
when(inventoryPositions.selectById(901L)).thenReturn(position);
when(inventoryBalances.selectById(801L)).thenReturn(balance);
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "SKU-1", 1L)).thenReturn(List.of(first), List.of(second));
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1);
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7"));
service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13"));
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L);
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L);
assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus());
assertEquals(2, item.getPickedPackageCount());
}
@Test
void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
@@ -59,7 +59,7 @@ class DropshipOrderControllerContractTest {
"name: X-Internal-Service-Token",
"required: [customerId, orderId, warehouseId, idempotencyKey, pallets]",
"required: [palletNo, skuPackages]",
"required: [skuCode, packageCount, productQuantity, cartonMarks]",
"required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities]",
"data: { type: integer, format: int32, minimum: 0 }");
assertThat(yaml.indexOf("name: X-Internal-Service-Token"))
.isLessThan(yaml.indexOf("requestBody:"));
@@ -69,6 +69,6 @@ class DropshipOrderControllerContractTest {
return new DropshipRequests.Receive("100", 10L, 20L, "event-1",
List.of(new DropshipRequests.Pallet("PALLET-1",
List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2,
List.of("CARTON-1"))), 1, 1, 1, "0".repeat(64))));
List.of("CARTON-1"), java.util.Map.of("CARTON-1", 2L))), 1, 1, 1, "0".repeat(64))));
}
}
@@ -52,10 +52,10 @@ class DropshipOrderServiceTest {
List.of(
new DropshipRequests.Pallet("SOURCE-P-1", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20,
List.of("MARK-1", "MARK-2"))), 1, 2, 1, "0".repeat(64)),
List.of("MARK-1", "MARK-2"), java.util.Map.of("MARK-1", 7L, "MARK-2", 13L))), 1, 2, 1, "0".repeat(64)),
new DropshipRequests.Pallet("SOURCE-P-2", List.of(
new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10,
List.of("MARK-3"))), 1, 1, 1, "1".repeat(64))));
List.of("MARK-3"), java.util.Map.of("MARK-3", 10L))), 1, 1, 1, "1".repeat(64))));
assertThat(service.receive(request)).isEqualTo(3);
verifyNoInteractions(warehouse);
@@ -76,6 +76,8 @@ class DropshipOrderServiceTest {
.containsOnlyNulls();
ArgumentCaptor<DropshipCarton> cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class);
verify(cartons, times(3)).insert(cartonCaptor.capture());
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getProductQuantity)
.containsExactly(7L, 13L, 10L);
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus)
.containsOnly("PENDING_INBOUND");
ArgumentCaptor<DropshipCartonLedger> ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class);
@@ -141,6 +141,18 @@ class LoadingCompletedConsumerTest {
}
verify(dropshipOrders).receive(any());
}
@Test void preservesNonUniformCartonQuantitiesAcrossTheEventBoundary() {
EventEnvelope original=overseasDropshipEvent(false);
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
var sku=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("pallets").get(0).path("skuPackages").get(0);
var quantities=sku.putObject("cartonQuantities");
quantities.put("RK1#5-1",6).put("RK1#5-2",7).put("RK1#5-3",8).put("RK1#5-4",9).put("RK1#5-5",20);
service.consume(rehash(original,node));
var captured=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
verify(dropshipOrders).receive(captured.capture());
assertEquals(Map.of("RK1#5-1",6L,"RK1#5-2",7L,"RK1#5-3",8L,"RK1#5-4",9L,"RK1#5-5",20L),
captured.getValue().pallets().get(0).skuPackages().get(0).cartonQuantities());
}
@Test void rejectsAmbiguousDropshipSkuSourceBeforeWritingReceivingLabels() {
var failure=assertThrows(IllegalArgumentException.class,()->service.consume(overseasDropshipEvent(true)));
assertTrue(failure.getMessage().contains("来源明细"));
@@ -159,6 +171,7 @@ class LoadingCompletedConsumerTest {
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);
addManifest(secondPallet, 5);
service.consume(rehash(original,node));
@@ -230,5 +243,15 @@ class LoadingCompletedConsumerTest {
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); }
private void addManifest(JsonNode pallet,int packageCount){
var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet;
object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64));
// These fixtures explicitly represent uniform cartons; production must never infer this ratio.
for(JsonNode sku:object.path("skuPackages")) {
int count=sku.path("packageCount").asInt(), quantity=sku.path("productQuantity").asInt();
assertTrue(count>0 && quantity%count==0);
var quantities=((com.fasterxml.jackson.databind.node.ObjectNode)sku).putObject("cartonQuantities");
for(JsonNode mark:sku.path("cartonMarks")) quantities.put(mark.asText(),quantity/count);
}
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ export async function run(context) {
## 可用上下文
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账;finally 停用本轮操作员。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;尚未包含专用出库验收。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。
@@ -2,6 +2,17 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { sameOutboundTask } from './dropship-pallet-matrix.mjs'
test('outbound replay preserves business identity and database millisecond timestamps', () => {
const task = { id: '1', taskNo: 'PDA1', outboundNo: 'OD1', status: 'PENDING', pushedAt: '2026-09-14T20:24:45.920278604' }
assert.equal(sameOutboundTask(task, { ...task, pushedAt: '2026-09-14T20:24:45.92' }), true)
assert.equal(sameOutboundTask({ ...task, pushedAt: '2026-09-14T20:24:45.9999' },
{ ...task, pushedAt: '2026-09-14T20:24:46' }), true)
for (const key of ['id', 'taskNo', 'outboundNo', 'status', 'pushedAt']) {
assert.equal(sameOutboundTask(task, { ...task, [key]: 'changed' }), false)
}
})
const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
@@ -16,6 +16,16 @@ const MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
export function sameOutboundTask(left, right) {
const databaseTime = (value) => {
const match = typeof value === 'string' && value.match(/^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
return match ? Date.parse(match[1]) + Math.round(Number(`0.${match[2] || '0'}`) * 1000) : NaN
}
return ['id', 'taskNo', 'outboundNo', 'status'].every((key) => left?.[key] === right?.[key])
&& ['pushedAt', 'completedAt'].every((key) => left?.[key] == null && right?.[key] == null
|| Number.isFinite(databaseTime(left?.[key])) && databaseTime(left[key]) === databaseTime(right?.[key]))
}
// Domestic producer stage: these retained loaded fixtures must subsequently be verified
// through overseas receipt and dedicated dropship outbound, not generic dispatch.
export async function run(context) {
@@ -415,12 +425,13 @@ export async function run(context) {
const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
for (const record of group) record.outbound = { task, selected, completed: false }
const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
check(`${label}专用出库重放不重复建单`, null, JSON.stringify(replay) === JSON.stringify(task))
check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task))
const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}`
const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true)
check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length)
for (const [index, candidate] of selected.entries()) {
const item = detail.items.find((row) => row.palletNo === candidate.palletNo && row.skuCode === candidate.skuCode)
const item = detail.items.find((row) => row.palletNo === candidate.palletNo && row.skuCode === candidate.skuCode
&& row.inboundCode === candidate.inboundCode)
check(`${label}PDA拣货来源仓位和入仓码一致`, null, Boolean(item)
&& item.positions?.some((position) => String(position.inventoryPositionId) === String(candidate.inventoryPositionId)
&& position.inboundCode === candidate.inboundCode))
@@ -429,16 +440,18 @@ export async function run(context) {
cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` }
const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true)
const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true)
check(`${label}PDA出库拣货重放不重复扣减`, null, JSON.stringify(repeated) === JSON.stringify(picked))
check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked))
}
const completion = { idempotencyKey: `${context.marker}-outbound-complete` }
const completed = await call('PDA完成专用出库', 'POST', `${pdaPath}/complete`, completion, true)
const repeated = await call('PDA专用出库完成重放', 'POST', `${pdaPath}/complete`, completion, true)
check(`${label}专用出库任务完成且重放稳定`, null,
completed.status === 'COMPLETED' && JSON.stringify(repeated) === JSON.stringify(completed))
completed.status === 'COMPLETED' && sameOutboundTask(repeated, completed),
JSON.stringify({ completed, repeated }))
const outbound = await call('回查专用出库单终态', 'GET', `${base}/${task.outboundNo}`)
check(`${label}专用出库单真实完成且箱数核平`, null,
outbound.outbound?.status === 'OUTBOUNDED' && outbound.outbound?.outboundType === 'ONE_CLICK_DROPSHIP'
&& Number(outbound.outbound.palletCount) === new Set(selected.map((item) => item.palletNo)).size
&& Number(outbound.outbound.packageCount) === inputs.reduce((sum, item) => sum + item.packages, 0)
&& outbound.pallets.every((item) => Number(item.packageCount) === Number(item.pickedPackageCount)))
const remaining = await call('回查专用出库可用库存扣减', 'GET', candidatePath)