fix(dropship): preserve carton quantities and shared pallet outbound ownership
This commit is contained in:
+26
-3
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
+7
@@ -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);
|
||||
|
||||
+30
-11
@@ -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: 取消未执行出库单
|
||||
|
||||
+1
-1
@@ -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"));
|
||||
|
||||
+97
-1
@@ -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");
|
||||
|
||||
+2
-2
@@ -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))));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
+24
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user