fix(lastleg): accept cabinet pallet identities

This commit is contained in:
2026-09-18 14:14:10 +08:00
parent 2d902308b5
commit 895d0f7639
9 changed files with 49 additions and 15 deletions
@@ -255,7 +255,7 @@ public class ContainerInboundPlanConversionCoordinator {
String fbaCode = trimToNull(order.fbaWarehouseCode());
if (fbaCode != null && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
for (String palletCode : sourcePalletNos) {
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
PalletCodeParts parts = palletCodeParts(palletCode);
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
if (previousPrefix != null && !previousPrefix.equals(parts.prefix())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一 FBA 仓的头程托盘码内部编码不一致");
@@ -487,17 +487,16 @@ public class ContainerInboundPlanConversionCoordinator {
int localSequenceNo, int globalSequenceNo, String businessKey,
String fbaWarehouseCode, String palletCode) { }
private static PalletCodeParts palletCodeParts(String containerNo, String palletCode) {
String stableContainerNo = trimToNull(containerNo);
private static PalletCodeParts palletCodeParts(String palletCode) {
String stablePalletCode = trimToNull(palletCode);
if (stableContainerNo == null || stablePalletCode == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "集装箱号或头程托盘码缺失");
if (stablePalletCode == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码缺失");
}
Matcher matcher = Pattern.compile("^(" + Pattern.quote(stableContainerNo) + "-.+)-(\\d{3})$")
Matcher matcher = Pattern.compile("^(D\\d{4,}-.+)-(\\d{3})$")
.matcher(stablePalletCode);
if (!matcher.matches()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"头程托盘码不符合‘集装箱号-FBA内部编码-下标’规则");
"头程托盘码不符合‘柜次号-FBA内部编码-下标’规则");
}
return new PalletCodeParts(matcher.group(1), Integer.parseInt(matcher.group(2)));
}
@@ -345,7 +345,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"MSCU6639870-101-001", "MSCU6639870-101-002"));
"D0004-101-001", "D0004-101-002"));
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
@@ -359,7 +359,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
throw new AssertionError(exception);
}
assertEquals(List.of("MSCU6639870-101-001", "MSCU6639870-101-002", "MSCU6639870-101-003"),
assertEquals(List.of("D0004-101-001", "D0004-101-002", "D0004-101-003"),
preparation.items().stream().map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
}
@@ -376,7 +376,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
1, 1, 3, 2, 1, 3, 3, 3, List.of(order))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"MSCU6639870-101-001", "MSCU6639870-101-002"));
"D10000-101-001", "D10000-101-002"));
when(plans.existingPlanIdentity("100", 11L))
.thenReturn(new InboundPlanService.PlanIdentity(21L, "IP-MANUAL", 3));
@@ -434,7 +434,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
private InboundPlanResponse plan(String planNo, List<InboundPlanResponse.Pallet> pallets) {
return new InboundPlanResponse(21L, planNo, "100", 11L, "FL-WH-1", "验收客户", "ONE_CLICK_DROPSHIP",
"MSCU6639870", "US-LAX-01",
"Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.ONE, 10, 0,
"Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.TEN, new BigDecimal("8"), BigDecimal.ONE, 10, 0,
List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false,
false, false, "MANUAL", "自动生成", null, null);
}
@@ -30,7 +30,7 @@ export function containerFieldGaps(record, { requireContainerNo = false } = {})
cabinetNo: isNonBlank,
warehouseId: isPositiveId,
warehouseName: isNonBlank,
channelCode: isNonBlank,
channelGroupCode: isNonBlank,
containerType: isNonBlank,
capacityCbm: (value) => Number(value) > 0,
maxWeightKg: (value) => Number(value) > 0,
@@ -82,7 +82,7 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
const samples = [
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelGroupCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
[preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HQ', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'],
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
@@ -110,7 +110,7 @@ async function verifyPalletLabels(context, fixture, loadingPlanId) {
const index = Number(label?.palletIndex)
return id(label?.containerNo) === id(fixture.containerNo)
&& id(label?.destinationWarehouseInternalCode)
&& id(label?.palletCode) === `${fixture.containerNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}`
&& id(label?.palletCode) === `${fixture.cabinetNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}`
})
const indexesValid = (manifest?.groups || []).every((group) =>
(group?.labels || []).every((label, index) => Number(label?.palletIndex) === index + 1))
@@ -580,6 +580,7 @@ export async function run(context) {
destinationWarehouseId: id(fixture.destinationWarehouseId),
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
palletLabelManifest,
cabinetNo: fixture.cabinetNo || null,
containerNo: fixture.containerNo || null,
sourceStatus: 'LOADED',
verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP',
@@ -1715,7 +1715,8 @@ export async function run(context) {
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
immutableEvidenceId: id(immutablePhoto?.id), immutableEvidenceVersion: Number(immutablePhoto?.version),
postLoadedFileId,
oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId,
oceanBookingId: bookingId, loadingPlanId, containerBookingId,
cabinetNo: simulation.cabinetNo, containerNo, warehouseId,
canonicalPdaTaskId: canonicalTaskId,
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
@@ -81,10 +81,13 @@ test('measurement and loading pass destination identity into the LOADED producer
const finalize = read('phase3-loading-finalize.mjs')
assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/)
assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/)
assert.match(loading, /cabinetNo: simulation\.cabinetNo, containerNo/)
assert.match(loading, /fulfillmentType: source\?\.fulfillmentType \|\| 'STANDARD'/)
assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuCodes\)/)
assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuLines\)/)
assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/)
assert.match(finalize, /cabinetNo: fixture\.cabinetNo \|\| null/)
assert.match(finalize, /`\$\{fixture\.cabinetNo\}-\$\{label\.destinationWarehouseInternalCode\}-/)
assert.match(finalize, /fulfillmentType: fixture\.fulfillmentType \|\| 'STANDARD'/)
assert.match(finalize, /skuCodes: Array\.isArray\(fixture\.skuCodes\) \? fixture\.skuCodes : \[\]/)
assert.match(finalize, /skuLines: Array\.isArray\(fixture\.skuLines\) \? fixture\.skuLines : \[\]/)
@@ -103,6 +103,9 @@ test('PDA module covers the container-number receiving contract and excludes out
for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden)
assert.match(source, /同键同载荷重放/)
assert.match(source, /inbound-plans\/container-preview/)
assert.match(source, /orders\/container-arrival-times\/query/)
assert.match(source, /container-summaries\/\$\{encodeURIComponent\(bookingId\)\}\/expected-arrival/)
assert.match(source, /estimatedWarehouseArrivalAt, expectedVersion: Number\(arrivalTime\.version\)/)
assert.match(source, /snapshotVersion: Number\(preview\.snapshotVersion\)/)
assert.match(source, /snapshotHash: String\(preview\.snapshotHash\)/)
assert.match(source, /rememberGeneratedPlans\(context, plans\)/)
@@ -336,6 +336,33 @@ async function convertReceivingContainers(context, producers) {
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
if (!convertible) return false
const bookingId = id(summary.sourceContainerBookingId)
const arrivalQueryResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/orders/container-arrival-times/query',
{ sourceContainerBookingIds: [bookingId] }, {}, context.overseasToken)
const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data)
? arrivalQueryResponse.body.data : []
let arrivalTime = arrivalTimes.find((item) => id(item?.sourceContainerBookingId) === bookingId)
context.addStep(MODULE, `读取整柜预计到仓时间 ${containerNo}`, arrivalQueryResponse,
Boolean(arrivalTime) && Number.isInteger(Number(arrivalTime?.version)),
`sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalQueryResponse)}`,
OPTIONS)
if (!arrivalTime) return false
if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) {
const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19)
const arrivalUpdateResponse = await context.requestOverseas('PUT',
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`,
{ estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version),
remark: `${context.marker}-overseas-inbound-plan-prerequisite` },
{ 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) },
context.overseasToken)
arrivalTime = success(arrivalUpdateResponse) ? arrivalUpdateResponse.body?.data : null
context.addStep(MODULE, `维护整柜预计到仓时间 ${containerNo}`, arrivalUpdateResponse,
id(arrivalTime?.sourceContainerBookingId) === bookingId
&& nonBlank(arrivalTime?.estimatedWarehouseArrivalAt),
`sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalUpdateResponse)}`,
OPTIONS)
if (!arrivalTime || !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) return false
}
const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}`
const previewResponse = await context.requestOverseas('GET', previewPath,
undefined, {}, context.overseasToken)