From e30255ff58341b62d20269e9d99538153bb52ea3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 19:14:47 +0800 Subject: [PATCH] fix: complete pallet code cross-region flow --- ...ainerInboundPlanConversionCoordinator.java | 7 +- ...rInboundPlanConversionCoordinatorTest.java | 32 +++++++++ .../phase3-loading-finalize.mjs | 66 +++++++++++++++++++ .../phase3-producer-chain-contract.test.mjs | 19 ++++++ .../phase4-overseas-pda.mjs | 18 +++++ .../phase4-tail-order-controls.mjs | 14 ++-- .../dto/WarehousePalletBatchRequests.java | 2 +- .../service/WarehousePalletBatchService.java | 4 +- .../WarehousePalletBatchServiceTest.java | 22 +++++++ 9 files changed, 175 insertions(+), 9 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 3f3767265..5eff2d693 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -209,13 +209,16 @@ public class ContainerInboundPlanConversionCoordinator { String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); List sourcePalletNos = plans.sourcePalletNos( order.customerId(), Long.parseLong(order.orderId()), order.orderType()); - if (sourcePalletNos.size() != order.suggestedPalletCount()) { + boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null + && !"ONE_CLICK_DROPSHIP".equals(order.orderType()); + if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致"); } for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, - trimToNull(order.fbaWarehouseCode()), sourcePalletNos.get(localSequence - 1))); + trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets + ? null : sourcePalletNos.get(localSequence - 1))); } } Preparation preparation = new Preparation(warehouseId, List.copyOf(items)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 084121a52..2dea90075 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -258,6 +258,38 @@ class ContainerInboundPlanConversionCoordinatorTest { .map(ContainerInboundPlanConversionCoordinator.PreparedItem::fbaWarehouseCode).toList()); } + @Test + void standardOverseasOrdersLetWarehouseGeneratePhysicalPalletCodes() { + ConvertContainerInboundPlanRequest request = request(); + var overseas = new ContainerInboundPlanPreviewResponse.Order( + "200", "Customer 200", "12", "LM-12", "RK-OVERSEAS", "FIRST_LEG", "FIRST_LEG", + "31", "US-LAX-01", "Los Angeles", null, 10, BigDecimal.ONE, 2, + null, null, null, 0, "READY", List.of()); + var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", + "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 2, 2, 0, 1, 0, true, + List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles", + 1, 1, 2, 0, 2, 0, List.of(overseas)))); + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); + when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS"); + when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of()); + + ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", + 1L, 2L, BOOKING_ID, "FIRST-KEY", request, + ReflectionTestUtils.invokeMethod(coordinator, "hash", + List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash()))); + ContainerInboundPlanConversionCoordinator.Preparation preparation; + try { + preparation = objectMapper.readValue(command.getPreparationJson(), + ContainerInboundPlanConversionCoordinator.Preparation.class); + } catch (Exception exception) { + throw new AssertionError(exception); + } + + assertEquals(2, preparation.items().size()); + assertEquals(java.util.Arrays.asList(null, null), preparation.items().stream() + .map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList()); + } + private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId, String inboundCode, String destinationCode, String fbaWarehouseCode) { diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 8d705c2d0..a6a16f044 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -78,6 +78,70 @@ async function request(context, method, path, body, headers = {}) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } + +async function requestPdf(context, path, body) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', + headers: { Accept: 'application/pdf', Authorization: `Bearer ${context.token}`, + 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body), signal: AbortSignal.timeout(60_000), + }) + const bytes = new Uint8Array(await response.arrayBuffer()) + return { method: 'POST', path, http: response.status, body: null, raw: null, + contentType: response.headers.get('content-type'), byteLength: bytes.length, + pdfMagic: String.fromCharCode(...bytes.slice(0, 4)), + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } + } catch (error) { + return { method: 'POST', path, http: 0, body: null, raw: String(error), contentType: null, + byteLength: 0, pdfMagic: null, requestId: null, traceId: null } + } +} + +async function verifyPalletLabels(context, fixture, loadingPlanId) { + if (fixture.destinationType !== 'FBA' || fixture.fulfillmentType === 'ONE_CLICK_DROPSHIP') return null + const warehouseId = id(fixture.warehouseId) + const base = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(loadingPlanId)}/pallet-labels` + const path = `${base}/prepare?warehouseId=${encodeURIComponent(warehouseId)}` + const preparedResponse = await context.request('POST', path, {}) + const manifest = context.apiSuccess(MODULE, '生成并读取按 FBA 仓分组的散货托盘码清单', preparedResponse, '', OPTIONS) + const labels = (manifest?.groups || []).flatMap((group) => group?.labels || []) + const codesValid = labels.length > 0 && labels.every((label) => { + 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')}` + }) + const indexesValid = (manifest?.groups || []).every((group) => + (group?.labels || []).every((label, index) => Number(label?.palletIndex) === index + 1)) + context.assert(MODULE, '散货托盘码使用柜号-FBA内部编号-仓内连续下标', preparedResponse, + id(manifest?.containerNo) === id(fixture.containerNo) + && Number(manifest?.totalCount) === labels.length && codesValid && indexesValid, + `containerNo=${manifest?.containerNo || 'missing'}, groups=${manifest?.groups?.length ?? 0}, labels=${labels.length}`, + OPTIONS) + + const replayResponse = await context.request('POST', path, {}) + const replay = context.apiSuccess(MODULE, '重复生成散货托盘码返回稳定清单', replayResponse, '', OPTIONS) + context.assert(MODULE, '散货托盘码重复生成零漂移', replayResponse, + JSON.stringify(replay) === JSON.stringify(manifest), `labels=${labels.length}`, OPTIONS) + + const printPath = `${base}/print?warehouseId=${encodeURIComponent(warehouseId)}` + const allPdf = await requestPdf(context, printPath, { destinationWarehouseCode: null, count: null }) + context.addStep(MODULE, '打印全部 FBA 仓散货托盘码 PDF', allPdf, + allPdf.http === 200 && allPdf.contentType?.toLowerCase().includes('application/pdf') + && allPdf.pdfMagic === '%PDF' && allPdf.byteLength > 0, + `HTTP=${allPdf.http}, type=${allPdf.contentType || 'missing'}, bytes=${allPdf.byteLength}`, OPTIONS) + const group = manifest?.groups?.[0] + const selectedPdf = await requestPdf(context, printPath, + { destinationWarehouseCode: group?.destinationWarehouseCode, count: 1 }) + context.addStep(MODULE, '按指定 FBA 仓补打 1 张托盘码 PDF', selectedPdf, + Boolean(group?.destinationWarehouseCode) && selectedPdf.http === 200 + && selectedPdf.contentType?.toLowerCase().includes('application/pdf') + && selectedPdf.pdfMagic === '%PDF' && selectedPdf.byteLength > 0, + `warehouse=${group?.destinationWarehouseCode || 'missing'}, HTTP=${selectedPdf.http}, bytes=${selectedPdf.byteLength}`, + OPTIONS) + return manifest +} function blockResourceIdentity(resource) { return JSON.stringify({ blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo), @@ -490,6 +554,7 @@ export async function run(context) { inventoryBlockSettlementVerified: expectedBlocks.length > 0 && releasedOrTransferredBlocks.every((item) => item.verified) && completedInventoryReleased, } + const palletLabelManifest = await verifyPalletLabels(context, fixture, loadingPlanId) const producer = { marker: context.context?.phase4ProducerMatrixMarker || context.marker, eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', @@ -511,6 +576,7 @@ export async function run(context) { destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, + palletLabelManifest, containerNo: fixture.containerNo || null, sourceStatus: 'LOADED', verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP', diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index a3b21bb01..8fd53a5c5 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -69,6 +69,25 @@ test('records LOADED and clears the reason only after a real LOADED reread', () assert.equal(result.notCleanedReason, null) }) +test('finalize acceptance covers stable pallet generation and both print modes', () => { + const value = source('./phase3-loading-finalize.mjs') + assert.match(value, /pallet-labels`/) + assert.match(value, /`\$\{base\}\/prepare\?warehouseId=/) + assert.match(value, /柜号-FBA内部编号-仓内连续下标/) + assert.match(value, /重复生成散货托盘码返回稳定清单/) + assert.match(value, /destinationWarehouseCode: null, count: null/) + assert.match(value, /按指定 FBA 仓补打 1 张托盘码 PDF/) + assert.match(value, /pdfMagic === '%PDF'/) + assert.match(value, /palletLabelManifest,/) +}) + +test('overseas conversion proves source-code preservation and native code generation boundaries', () => { + const value = source('./phase4-overseas-pda.mjs') + assert.match(value, /头程 FBA 散货托盘码跨区原样用于实体托盘/) + assert.match(value, /标准海外仓订单由海外仓生成本地实体托盘码/) + assert.match(value, /actualPalletCodes\.every\(\(code\) => code\.startsWith\('PLT-'\)\)/) +}) + test('container loading follows push, claim, real pick, completed sequence', () => { const value = source('./phase3-container-loading.mjs') assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 87c6fb98c..41c9bdf2a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -350,6 +350,24 @@ async function convertReceivingContainers(context, producers) { && convertedInboundCodes.size === inboundCodes, `plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS) if (plans.length !== inboundCodes) return false + const actualPalletCodes = plans.flatMap((item) => Array.isArray(item?.pallets) ? item.pallets : []) + .map((pallet) => id(pallet?.palletNo)).filter(nonBlank).sort() + const expectedPalletCodes = (producer?.palletLabelManifest?.groups || []) + .flatMap((group) => group?.labels || []).map((label) => id(label?.palletCode)).filter(nonBlank).sort() + if (producer?.destinationType === 'FBA' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') { + context.assert(MODULE, `头程 FBA 散货托盘码跨区原样用于实体托盘 ${containerNo}`, convertedResponse, + expectedPalletCodes.length > 0 + && JSON.stringify(actualPalletCodes) === JSON.stringify(expectedPalletCodes), + `source=${expectedPalletCodes.join(',') || 'missing'}, overseas=${actualPalletCodes.join(',') || 'missing'}`, + OPTIONS) + } + if (producer?.destinationType === 'OVERSEAS' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') { + context.assert(MODULE, `标准海外仓订单由海外仓生成本地实体托盘码 ${containerNo}`, convertedResponse, + expectedPalletCodes.length === 0 && actualPalletCodes.length > 0 + && actualPalletCodes.every((code) => code.startsWith('PLT-')), + `sourceCount=${expectedPalletCodes.length}, overseas=${actualPalletCodes.join(',') || 'missing'}`, + OPTIONS) + } rememberGeneratedPlans(context, plans) const replayResponse = await context.requestOverseas('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index 19c9e44ba..65edf988f 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -171,14 +171,20 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128) const fee = await api(context, 'POST', '新增本轮尾程订单费用', query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }), - { feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker }) + { feeItemCode: feeItem.slice(0, 64), feeItemName: feeItem, direction: 'RECEIVABLE', + currency: 'USD', amount: 0.01, businessDate: new Date().toISOString().slice(0, 10) }, + { 'Idempotency-Key': `${context.marker}-tail-fee`.slice(0, 128) }) context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, - id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem - && fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW', + id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemName === feeItem + && fee.data?.originalCurrency === 'USD' && Number(fee.data?.originalAmount) === 0.01 + && fee.data?.currency === 'CNY' && Number(fee.data?.amount) > 0 + && fee.data?.reviewStatus === 'PENDING', trace(fee.response), OPTIONS) const feeRead = await detail(context, representative, '回查尾程订单费用明细') context.assert(MODULE, '尾程费用详情可追溯', feeRead.response, - feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS) + feeRead.data?.fees?.some((row) => row?.feeItemName === feeItem + && row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01 + && row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS) const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { customerId: id(representative.customerId), containerNo: producer.containerNo, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java index 1cf904435..647ff1a70 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java @@ -24,7 +24,7 @@ public final class WarehousePalletBatchRequests { @Positive int sequenceNo, @NotBlank @Size(max = 32) String palletType, @Size(max = 128) String fbaWarehouseCode, - @NotBlank @Size(max = 128) String palletCode + @Size(max = 128) String palletCode ) { } public record Confirm( diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java index f0de25ab7..407b7ecf1 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -257,8 +257,8 @@ public class WarehousePalletBatchService { if (item.fbaWarehouseCode() != null) { requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long"); } - String palletCode = required(item.palletCode(), "Pallet code is required"); - requireMax(palletCode, 128, "Pallet code is too long"); + String palletCode = trim(item.palletCode()); + if (palletCode != null) requireMax(palletCode, 128, "Pallet code is too long"); if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique"); if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique"); return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo, diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java index bde250a7b..c1baf254d 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java @@ -86,6 +86,28 @@ class WarehousePalletBatchServiceTest { .containsExactly("container:plan-a:1", "container:plan-b:2"); } + @Test + void missingSourcePalletCodeUsesWarehouseGeneratedPhysicalCode() { + when(batches.insertIgnore(any())).thenReturn(1); + when(resources.createPallet(anyLong(), anyLong(), any())).thenReturn(301L); + when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301")); + when(locks.reserve(any())).thenAnswer(call -> { + WarehouseResourceLockRequests.ReserveRequest value = call.getArgument(0); + return lockResponse(value.customerId(), 501L); + }); + var request = new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-generated", List.of( + new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:generated:1", "PLAN-G", 1, + "STANDARD", null, null))); + + var response = service.ensureReserved(request); + + ArgumentCaptor saves = ArgumentCaptor.forClass(PalletRequests.SaveRequest.class); + verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE), + org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture()); + assertThat(saves.getValue().palletCode()).isNull(); + assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301"); + } + @Test void concurrentSameRequestReplaysWinningBatchWithoutCreatingResources() { AtomicReference inserted = new AtomicReference<>();