diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index fffa62846..563a61af4 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2020,9 +2020,9 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command ( idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要', snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', - status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', + status VARCHAR(32) NOT NULL COMMENT '转换状态:PREPARED、APPLYING、LOCAL_COMMITTED或COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + 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_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at) ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index a536e8f10..74315f3b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -300,7 +300,8 @@ public class PdaPalletOperationService { if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) { throw conflict("解绑必须清空来源托盘"); } - changes.sort(Comparator.comparingLong(change -> change.pallet().palletId())); + changes.sort(Comparator.comparingInt((PalletChange change) -> "IDLE".equals(change.targetStatus()) ? 0 : 1) + .thenComparingLong(change -> change.pallet().palletId())); return new Plan(pallets, moves, changes); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 0aae8ab30..1a3b49e7d 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -589,12 +589,12 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command ( snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', - status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED', + status VARCHAR(32) NOT NULL COMMENT '转换状态:PREPARED、APPLYING、LOCAL_COMMITTED或COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + 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_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index d88cb4b1a..3c0a6ac9e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -18,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; @@ -74,19 +75,21 @@ class PdaPalletOperationServiceTest { } @Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() { - var source = pallet(10L, "SOURCE", 88L, 1); - var target = pallet(20L, "TARGET", 88L, 2); + var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, + "OCCUPIED", null, 1); + var target = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, + "IDLE", null, 2); when(physical.resolve("SOURCE")).thenReturn(source); when(physical.resolve("TARGET")).thenReturn(target); when(physical.resolveLocation(88L, target.locationId())).thenReturn( new PalletPhysicalGateway.LocationReference(target.locationId(), "LOC-" + target.locationId(), "STORAGE", "ACTIVE")); - PalletInventoryRow planned = row(100L, 10L, 1L, 5L); - PalletInventoryRow stale = row(100L, 10L, 2L, 5L); - when(mapper.content("88", 10L)).thenReturn(List.of(planned)); - when(mapper.content("88", 20L)).thenReturn(List.of()); - when(mapper.palletPackages("88", 10L)).thenReturn(5L); - when(mapper.palletPackages("88", 20L)).thenReturn(0L); + PalletInventoryRow planned = row(100L, 20L, 1L, 5L); + PalletInventoryRow stale = row(100L, 20L, 2L, 5L); + when(mapper.content("88", 20L)).thenReturn(List.of(planned)); + when(mapper.content("88", 10L)).thenReturn(List.of()); + when(mapper.palletPackages("88", 20L)).thenReturn(5L); + when(mapper.palletPackages("88", 10L)).thenReturn(0L); when(mapper.lockPosition("88", 100L)).thenReturn(stale); AtomicReference operation = new AtomicReference<>(); @@ -103,6 +106,9 @@ class PdaPalletOperationServiceTest { assertThrows(ResponseStatusException.class, () -> service.assemble(identity(), request)); + InOrder transitions = inOrder(physical); + transitions.verify(physical).apply(eq(88L), eq(source), eq("IDLE"), anyLong(), anyString(), any()); + transitions.verify(physical).apply(eq(88L), eq(target), eq("OCCUPIED"), anyLong(), anyString(), any()); verify(physical, times(2)).compensate(eq(88L), any(PalletPhysicalGateway.Transition.class)); assertEquals("FAILED_COMPENSATED", operation.get().getStatus()); verify(positions, never()).updateById(any(com.baiyue.lastleg.inventory.entity.InventoryPosition.class)); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 44b83dfbe..8d514f990 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1387,6 +1387,8 @@ export class AcceptanceRun { internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, taxMode: fixtureOrder.taxMode || null, + finalStatus: fixture.finalStatus, + processStatus: fixture.processStatus, requireInventoryMutation: requireSharedOverflow, } : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true } const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC` @@ -1468,7 +1470,7 @@ export class AcceptanceRun { const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] const createdKeys = [ - 'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking', + 'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'inboundFees', 'oceanBooking', 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', ] @@ -1493,6 +1495,16 @@ export class AcceptanceRun { // run; repeating it for every producer is unsafe for a populated shared warehouse and can // become impossible when the bounded isolation guard refuses bulk location mutations. restoreFixture = await this.preparePhase3LoadingFixture('', false) + if (fulfillmentType === 'ONE_CLICK_DROPSHIP') { + const measuredFixture = this.createdResources.inboundMeasurement + this.createdResources.inboundMeasurement = this.createdResources.orchestrationFixtures?.phase3Loading + try { + const inboundFees = await import(new URL('./admin-test-env-modules/inbound-fees.mjs', import.meta.url)) + await this.executeModule(`inbound-fees-phase4-${label}`, () => inboundFees.run(this)) + } finally { + this.createdResources.inboundMeasurement = measuredFixture + } + } const loading = await import(new URL('./admin-test-env-modules/phase3-loading.mjs', import.meta.url)) await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this)) const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url)) diff --git a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs index 74cd40c71..499860872 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs @@ -8,9 +8,9 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8') const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') -test('three generic container documents are frozen and customs stays on customs-v1', () => { +test('two container operational documents are frozen and customs stays on customs-v1', () => { assert.deepEqual(DOCUMENT_TYPES, [ - 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER', + 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', ]) for (const token of [ '/clearance/document/upload', '/clearance/complete', @@ -28,11 +28,11 @@ test('document lifecycle is fail-closed, traceable and covers replay, hash confl ]) assert.ok(source.includes(token), token) }) -test('focused pickup-ready chain orders documents before outbound and candidate visibility', () => { +test('focused pickup-ready dependencies reach outbound and candidate visibility', () => { const modules = normalizeModuleOrder(['phase4-overseas-pda']) const required = [ - 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', - 'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries', + 'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch', + 'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', ] for (const module of required) assert.ok(modules.includes(module), module) diff --git a/scripts/admin-test-env-modules/phase3-container-documents.mjs b/scripts/admin-test-env-modules/phase3-container-documents.mjs index 6e39ae731..2f517b14f 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents.mjs @@ -3,7 +3,7 @@ const OPTIONS = { scenario: 'phase3-container-operational-documents', deployment const CONTAINERS = '/api/platform/v1/containers' export const DOCUMENT_TYPES = Object.freeze([ - 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER', + 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', ]) function id(value) { return value == null ? '' : String(value) } @@ -114,7 +114,7 @@ export async function run(context) { readPath: `${CONTAINERS}/${containerId}/clearance`, completePath: `${CONTAINERS}/${containerId}/clearance/complete`, }, - ...['DELIVERY_ORDER', 'PICKUP_ORDER'].map((type) => ({ + ...['DELIVERY_ORDER'].map((type) => ({ type, uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`, readPath: `${CONTAINERS}/${containerId}/documents/${type}`, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 119ce4a71..68203663c 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -254,7 +254,7 @@ export async function run(context) { dropshipCandidates.length > 0, `inboundId=${inboundId}, pallets=${dropshipCandidates.length}`, OPTIONS) } context.assert(MODULE, '柜次装柜候选与入库单、客户和仓库一致', candidateResponse, - Boolean(candidate?.inboundDetailId) && id(candidate?.accountId) === tenantId + Boolean(candidate?.inboundDetailId || id(candidate?.palletId)) && id(candidate?.accountId) === tenantId && id(candidate?.warehouseId) === warehouseId, `candidate=${Boolean(candidate)}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) if ((!candidate?.inboundDetailId && !id(candidate?.palletId)) || !warehouseId) return diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 03d5dba66..3f9380a89 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -85,10 +85,12 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) + assert.match(entry, /inbound-fees-phase4-/) + assert.match(entry, /createdResources\.inboundMeasurement = this\.createdResources\.orchestrationFixtures\?\.phase3Loading/) assert.match(entry, /skuCodes: Array\.isArray\(fixtureOrder\.skuCodes\) \? fixtureOrder\.skuCodes : \[\]/) assert.match(entry, /skuLines: Array\.isArray\(fixtureOrder\.skuLines\) \? fixtureOrder\.skuLines : \[\]/) const pda = read('phase4-overseas-pda.mjs') - assert.match(loading, /inbound-assignments/) + assert.match(loading, /pallet-assignments/) assert.match(loading, /canonicalPickItems\.map/) assert.match(loading, /pickedPackageCount: Number\(item\.plannedPackageCount\)/) assert.match(loading, /const dropshipWholePallet = source\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'/) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 46eb70a55..36cc8df3a 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -31,6 +31,8 @@ test('phase-five module covers dispatch edit, transfer, PDA pick/depart and deli assert.match(source, /READY_TO_DEPART/) assert.match(source, /OUTBOUNDED/) assert.match(source, /DELIVERING/) + assert.match(source, /expectedVersion: deliveryVersion/) + assert.match(source, /status: 'DELIVERED', expectedVersion: completeVersion/) }) test('phase-five module proves the client delivery create, list and nested detail contract', () => { diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index f9efe2dd5..def07f1e2 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -250,13 +250,30 @@ export async function run(context) { outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', trace(outbound.response), OPTIONS) + const deliveryBeforeStatus = await platform(context, 'GET', '读取派送状态更新即时版本', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) + const deliveryVersion = Number(deliveryBeforeStatus.data?.order?.version) + context.assert(MODULE, '派送详情提供状态乐观锁版本', deliveryBeforeStatus.response, + Number.isInteger(deliveryVersion) && deliveryVersion >= 0, + `version=${deliveryBeforeStatus.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeStatus.response)}`, OPTIONS) + if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return const delivering = await platform(context, 'PUT', '派送单推进配送中', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, { customerId }), - { status: 'DELIVERING', remark: `${context.marker}-delivering` }) + { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering` }) context.assert(MODULE, '派送状态进入DELIVERING', delivering.response, delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) + if (!success(delivering.response)) return + const deliveryBeforeComplete = await platform(context, 'GET', '读取派送完成即时版本', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) + const completeVersion = Number(deliveryBeforeComplete.data?.order?.version) + context.assert(MODULE, '配送中详情版本已递增', deliveryBeforeComplete.response, + Number.isInteger(completeVersion) && completeVersion > deliveryVersion, + `before=${deliveryVersion}, after=${deliveryBeforeComplete.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeComplete.response)}`, + OPTIONS) + if (!Number.isInteger(completeVersion) || completeVersion <= deliveryVersion) return const completed = await platform(context, 'POST', '确认派送完成并生成客户回访', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId })) + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), + { status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` }) const visitId = id(completed.data?.visitId) context.assert(MODULE, '派送完成生成PENDING回访记录', completed.response, completed.data?.deliveryStatus === 'DELIVERED' && completed.data?.visitStatus === 'PENDING' @@ -264,7 +281,8 @@ export async function run(context) { if (!success(completed.response) || !visitId) return context.createdResources.phase5OverseasFulfillment.visitId = visitId const completeReplay = await platform(context, 'POST', '确认派送完成重复调用', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId })) + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), + { status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` }) context.assert(MODULE, '派送完成重放复用同一回访', completeReplay.response, completeReplay.data?.replayed === true && id(completeReplay.data?.visitId) === visitId, trace(completeReplay.response), OPTIONS) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 2e54c5144..daef143bb 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -163,6 +163,8 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) assert.match(source, /runUntilPickupReady\(this\)/) const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix')) + assert.ok(variant.indexOf('inbound-fees-phase4-') < variant.indexOf('phase3-loading-phase4-')) + assert.match(variant, /fulfillmentType === 'ONE_CLICK_DROPSHIP'/) assert.ok(variant.indexOf('phase3-outbound-dispatch-phase4-') < variant.indexOf('phase3-container-documents-phase4-')) assert.ok(variant.indexOf('phase3-container-documents-phase4-') < variant.indexOf('phase3-carrier-schedule-queries-phase4-')) assert.match(source, /\['phase3-container-documents', \['phase3-outbound-dispatch'\]\]/)