This commit is contained in:
2026-10-15 18:25:53 +08:00
parent caff693ad8
commit cda5f3dc32
10 changed files with 109 additions and 16 deletions
@@ -4528,6 +4528,11 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
-- Appointment work scope, audit and physical batch allocation.
USE baiyue_lastleg;
@@ -111,10 +111,12 @@ public final class WarehouseTransferRequests {
@NotNull @DecimalMin("0.01") BigDecimal width,
@NotNull @DecimalMin("0.01") BigDecimal height,
@NotNull @DecimalMin("0.01") BigDecimal weight,
@NotBlank @Size(max = 255) String skuCode,
@Positive @Max(2147483647) long quantity
@NotEmpty @Size(max = 200) List<@Valid @NotNull CountSku> items
) {}
public record CountSku(@NotBlank @Size(max = 255) String skuCode,
@Positive @Max(2147483647) long quantity) {}
public record Palletize(
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
@@ -9,7 +9,8 @@ public final class WarehouseTransferResponses {
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {}
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status,
Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, Long quantity, String discrepancyReason, String countedBy, LocalDateTime countedAt) {}
BigDecimal weight, Long quantity, String discrepancyReason, String countedBy, LocalDateTime countedAt, List<CartonItem> items) {}
public record CartonItem(Long transferItemId, String skuCode, long quantity) {}
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
public record DestinationResult(String customerId, String transferNo) {}
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
@@ -25,6 +25,7 @@ public class WarehouseTransferCarton {
private java.math.BigDecimal heightCm;
private java.math.BigDecimal grossWeightKg;
private Long countedQuantity;
private String skuItemsJson;
private String countIdempotencyKey;
private String countRequestHash;
private String discrepancyReason;
@@ -535,7 +535,7 @@ public class WarehouseTransferService {
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(),
value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt(), cartonContents(value))).toList();
List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null
? List.of() : transferPalletItems.listOrder(customerId, transfer.getId());
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
@@ -621,7 +621,7 @@ public class WarehouseTransferService {
if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels");
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()).stream()
.filter(value -> !"NOT_RECEIVED".equals(value.getStatus()))
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt(), cartonContents(value))).toList();
StringBuilder labels = new StringBuilder();
for (WarehouseTransferResponses.Carton carton : cartons) {
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
@@ -829,8 +829,18 @@ public class WarehouseTransferService {
throw badRequest("箱号不属于本单或重复填写");
if (carton == null && input.transferItemId() == null) throw badRequest("新增实收箱必须选择本单申报明细");
var item = byItem.get(carton == null ? input.transferItemId() : carton.getTransferItemId());
if (item == null || !item.getSkuCode().equals(input.skuCode())) throw badRequest("箱内 SKU 必须与该箱申报明细一致");
if (input.quantity() <= 0 || input.quantity() > Integer.MAX_VALUE || input.length() == null || input.width() == null
if (item == null) throw badRequest("箱子申报明细不属于本单");
if (input.items() == null || input.items().isEmpty() || input.items().size() > 200)
throw badRequest("每箱必须填写 1 至 200 行 SKU");
Set<String> skuCodes = new java.util.HashSet<>();
for (var line : input.items()) {
if (line == null || !StringUtils.hasText(line.skuCode())
|| byItem.values().stream().noneMatch(declared -> declared.getSkuCode().equals(line.skuCode()))
|| !skuCodes.add(line.skuCode())) throw badRequest("箱内 SKU 必须来自本单预报,同箱不能重复");
if (line.quantity() <= 0 || line.quantity() > Integer.MAX_VALUE) throw badRequest("每行 SKU 必须填写有效实点件数");
}
if (!skuCodes.contains(item.getSkuCode())) throw badRequest("请保留箱子原申报 SKU,可添加其他本单 SKU");
if (input.length() == null || input.width() == null
|| input.height() == null || input.weight() == null || input.length().signum() <= 0 || input.width().signum() <= 0
|| input.height().signum() <= 0 || input.weight().signum() <= 0) throw badRequest("每箱必须填写有效尺寸、重量和实点件数");
}
@@ -853,7 +863,14 @@ public class WarehouseTransferService {
}
carton.setTransferPalletId(input.transferPalletId());
carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height());
carton.setGrossWeightKg(input.weight()); carton.setCountedQuantity(input.quantity());
carton.setGrossWeightKg(input.weight());
carton.setCountedQuantity(input.items().stream().mapToLong(WarehouseTransferRequests.CountSku::quantity).sum());
List<WarehouseTransferResponses.CartonItem> contents = input.items().stream().map(line -> {
var declared = byItem.values().stream().filter(value -> value.getSkuCode().equals(line.skuCode())).findFirst().orElseThrow();
return new WarehouseTransferResponses.CartonItem(declared.getId(), line.skuCode(), line.quantity());
}).toList();
try { carton.setSkuItemsJson(objectMapper.writeValueAsString(contents)); }
catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw new IllegalStateException("Cannot serialize carton contents", e); }
carton.setCountedBy(operatorId); carton.setCountedAt(now); carton.setStatus("COUNTED");
carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); carton.setUpdatedAt(now);
carton.setDiscrepancyReason(trim(request.discrepancyReason()));
@@ -871,6 +888,16 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
private List<WarehouseTransferResponses.CartonItem> cartonContents(WarehouseTransferCarton carton) {
if (!StringUtils.hasText(carton.getSkuItemsJson())) return List.of();
try {
return objectMapper.readValue(carton.getSkuItemsJson(),
new com.fasterxml.jackson.core.type.TypeReference<List<WarehouseTransferResponses.CartonItem>>() {});
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new IllegalStateException("Invalid persisted carton contents", e);
}
}
private List<WarehouseTransferCarton> requireCountedCartons(WarehouseTransferOrder transfer) {
var cartons = transferCartons.listOrder(transfer.getCustomerId(), transfer.getId());
if (cartons.isEmpty() || cartons.stream().anyMatch(box -> box.getCountedAt() == null)
@@ -4145,6 +4145,11 @@ CREATE TABLE IF NOT EXISTS ll_selfop_inventory_daily (
CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存';
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
-- Appointment work scope, audit and physical batch allocation.
USE baiyue_lastleg;
@@ -33,7 +33,7 @@ paths:
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/cartons/count:
post:
summary: 转运逐箱测量与清点
description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘并按申报SKU记录实点件数,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,超收生成系统箱号;差异需原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。
description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘,可包含多个本单预报SKU,逐项记录实点件数;同箱SKU不可重复,尺寸重量和实收箱数按实物箱计算一次,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,超收生成系统箱号;差异需原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。
parameters:
- {name: transferNo, in: path, required: true, schema: {type: string}}
requestBody:
@@ -52,7 +52,7 @@ paths:
maxItems: 5000
items:
type: object
required: [transferPalletId, length, width, height, weight, skuCode, quantity]
required: [transferPalletId, length, width, height, weight, items]
properties:
cartonMark: {type: string, maxLength: 255, nullable: true, description: 已有箱号;新增超收箱不传,由服务端生成}
transferItemId: {type: integer, format: int64, nullable: true, description: 新增超收箱所属本单申报明细}
@@ -61,10 +61,19 @@ paths:
width: {type: number, minimum: 0.01, description: 箱宽厘米}
height: {type: number, minimum: 0.01, description: 箱高厘米}
weight: {type: number, minimum: 0.01, description: 箱重千克}
items:
type: array
minItems: 1
maxItems: 200
description: 本箱实际SKU明细,仅限本单预报SKU,同箱不可重复
items:
type: object
required: [skuCode, quantity]
properties:
skuCode: {type: string, maxLength: 255}
quantity: {type: integer, minimum: 1, maximum: 2147483647, description: 箱内实点件数}
quantity: {type: integer, minimum: 1, maximum: 2147483647, description: 本SKU箱内实点件数}
responses:
'200': {description: 统一业务响应,成功data为转运详情,cartons含逐箱尺寸重量件数及清点操作人时间}
'200': {description: 统一业务响应,成功data为转运详情,cartons含逐箱尺寸重量、quantity合计件数、items逐SKU明细(transferItemId/skuCode/quantity)及清点操作人时间}
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
get:
summary: 查询单条库存余额详情
@@ -42,7 +42,7 @@ class WarehouseTransferCartonCountTest {
when(items.selectList(any())).thenReturn(List.of(ia,ib));
}
WarehouseTransferCarton box(String mark, long item) { var box = new WarehouseTransferCarton(); box.setCartonMark(mark); box.setCartonIndex((int)item); box.setTransferItemId(item); return box; }
WarehouseTransferRequests.CountCarton input(String mark, String sku, long quantity) { return new WarehouseTransferRequests.CountCarton(mark, null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),sku,quantity); }
WarehouseTransferRequests.CountCarton input(String mark, String sku, long quantity) { return new WarehouseTransferRequests.CountCarton(mark, null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),List.of(new WarehouseTransferRequests.CountSku(sku,quantity))); }
WarehouseTransferRequests.CountCartons request(String key, WarehouseTransferRequests.CountCarton... rows) { return new WarehouseTransferRequests.CountCartons(key,List.of(rows),null); }
@Test void multiSkuBoxesPersistDimensionsPiecesAndReplayWithoutWriting() {
var request = request("K1",input("BOX1","A",12),input("BOX2","B",7));
@@ -56,10 +56,47 @@ class WarehouseTransferCartonCountTest {
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K1",input("BOX1","A",13),input("BOX2","B",7))));
}
@Test void missingDuplicateForeignSkuAndInvalidQuantityRejectBeforeWrites() {
var invalid = List.of(request("K",input("BOX1","A",1)),request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","B",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2)));
var invalid = List.of(request("K",input("BOX1","A",1)),request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","FOREIGN",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2)));
for(var request:invalid) assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request));
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
}
@Test void mixedBoxPersistsEverySkuWithoutDuplicatingPhysicalBoxTotals() throws Exception {
var mixed = new WarehouseTransferRequests.CountCarton("BOX1", null, 200L,
new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),
List.of(new WarehouseTransferRequests.CountSku("A",3),new WarehouseTransferRequests.CountSku("B",7)));
var request = new WarehouseTransferRequests.CountCartons("MIX",List.of(mixed),"两种SKU装一箱");
service.countCartons("OP1",77L,"WT1",request);
assertEquals(10L,a.getCountedQuantity());
var contents = new ObjectMapper().readTree(a.getSkuItemsJson());
assertEquals(2,contents.size()); assertEquals("A",contents.get(0).get("skuCode").asText());
assertEquals(7,contents.get(1).get("quantity").asInt());
assertEquals(11L,contents.get(1).get("transferItemId").asLong());
assertEquals(1,pallet.getCartonCount()); assertEquals(new BigDecimal("0.024000"),pallet.getVolumeCbm());
assertEquals(new BigDecimal("2.5"),pallet.getGrossWeightKg());
var allocation = WarehouseTransferService.deriveCountedPalletAllocations(List.of(a),List.of(pallet));
assertEquals(1,allocation.size()); assertEquals(1,allocation.get(0).packageCount());
clearInvocations(cartons);
service.countCartons("OP1",77L,"WT1",request);
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
var response = (java.util.List<?>)ReflectionTestUtils.invokeMethod(service,"cartonContents",a);
assertEquals(2,response.size());
var changed = new WarehouseTransferRequests.CountCarton("BOX1",null,200L,mixed.length(),mixed.width(),mixed.height(),mixed.weight(),
List.of(new WarehouseTransferRequests.CountSku("A",4),new WarehouseTransferRequests.CountSku("B",6)));
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",
new WarehouseTransferRequests.CountCartons("MIX",List.of(changed),"两种SKU装一箱")));
}
@Test void duplicateUnknownEmptyAndInvalidSkuLinesRejectBeforeAnyWrites() {
var invalidLines = List.of(List.<WarehouseTransferRequests.CountSku>of(),
List.of(new WarehouseTransferRequests.CountSku("A",1),new WarehouseTransferRequests.CountSku("A",2)),
List.of(new WarehouseTransferRequests.CountSku("FOREIGN",1)),
List.of(new WarehouseTransferRequests.CountSku("A",0)),
List.of(new WarehouseTransferRequests.CountSku("A",2147483648L)));
for(var lines:invalidLines) {
var mixed = new WarehouseTransferRequests.CountCarton("BOX1",null,200L,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,lines);
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("BAD",mixed,input("BOX2","B",2))));
}
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
}
@Test void shortReceiptNeedsReasonAndPreservesMissingBoxFact() {
var request = new WarehouseTransferRequests.CountCartons("SHORT",List.of(input("BOX1","A",3)),"少到一箱");
service.countCartons("OP1",77L,"WT1",request);
@@ -67,7 +104,7 @@ class WarehouseTransferCartonCountTest {
assertEquals("少到一箱",b.getDiscrepancyReason()); assertNull(b.getCountedQuantity());
}
@Test void extraReceiptGeneratesOwnedBoxAndReconcilesToDeclaredSku() {
var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,"B",4);
var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,List.of(new WarehouseTransferRequests.CountSku("B",4)));
service.countCartons("OP1",77L,"WT1",new WarehouseTransferRequests.CountCartons("EXTRA",List.of(input("BOX1","A",1),input("BOX2","B",2),extra),"多到一箱"));
var captured = org.mockito.ArgumentCaptor.forClass(WarehouseTransferCarton.class);
verify(cartons).insert(captured.capture());
+2
View File
@@ -523,3 +523,5 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
`db/warehouse-transfer-consolidated-incremental-20261015.sql`:仓储转运近期本地增量汇总,固定 `USE baiyue_lastleg`,适用已有 Lastleg 基线的本地/开发 MySQL 8。依次合入 `warehouse-transfer-outbound-incremental.sql` 和 `warehouse-transfer-carton-counting-incremental.sql`,前者已覆盖旧 `dev-warehouse-transfer-pallet-lifecycle-incremental.sql`,无须重复跑旧生命周期补丁。包含审核、打托/照片、换标、目的地/出库/派送结构、实收箱数,以及逐箱尺寸、重量、实点件数、清点幂等、差异原因、箱所属托盘和独立 SKU 申报件数。只执行 DDL/元数据查询,无业务 DML;不回填单据或修改库存。实例和凭据由操作者在数据库客户端外部提供,无内置连接信息或报告文件。需支持 DELIMITER 和 CREATE ROUTINE/ALTER/INDEX 权限,整文件串行执行,遇错停止,不使用 --force;可按缺失结构检查重复执行,不修复任意同名异构旧表。末尾 `missing_carton_and_quantity_columns=0`、`inbound_pallet_unique_index=READY`、`migration_result=WAREHOUSE_TRANSFER_RECENT_SCHEMA_READY` 表示本轮列/索引核对通过;同客户重复托盘号会报告 INDEX_BLOCKED,不擅自更改旧托盘号。仅整理文件不表示目标库已经执行;连接必须由操作者明确选中,不重放 `deploy/sql/海外区域统一增量-MySQL8.sql` 空库基线。
`db/warehouse-transfer-carton-multi-sku-incremental.sql`:固定指向 `baiyue_lastleg`,按列存在性检查新增 `ll_warehouse_transfer_carton.sku_items_json`(JSON),保存同一实收箱的多个预报 SKU 及逐项件数。可重复执行,不更改或回填既有单据;需在启用新版转运清点后端前执行。
@@ -0,0 +1,4 @@
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;