diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 38897b09a..94eb4cc89 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import jakarta.annotation.Resource; @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; @Component public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { - @Resource private LoadingListRepository repository; + @Resource private CustomsRepository repository; @Override public String code() { return "CUSTOMS_CLEARANCE"; } @@ -18,12 +18,13 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION"); + CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), + context.inboundId(), context.containerBookingId()); if (value == null) return gate(false, "MISSING"); - return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED"); + return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 594eaf569..6695ea5d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class CustomsDispatchGateContributorTest { - @Mock LoadingListRepository repository; + @Mock CustomsRepository repository; @InjectMocks CustomsDispatchGateContributor contributor; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -22,15 +22,16 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCompletedSimpleDeclaration() { - ContainerDocumentEntity value = new ContainerDocumentEntity(); - when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value); + @Test void passesOnlyCanonicalClearanceCompletedCase() { + CustomsCaseEntity value = new CustomsCaseEntity(); + value.setStatus("RECEIPT_AVAILABLE"); + when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); assertThat(contributor.assess(context()).passed()).isFalse(); - value.setCompletedAt(java.time.LocalDateTime.now()); + value.setStatus("CLEARANCE_COMPLETED"); assertThat(contributor.assess(context())) .satisfies(item -> { assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); }); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index fd8ac7df0..a49339834 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -38,6 +38,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -325,7 +326,7 @@ public class ReceivingContainerService { } } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Action request) { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 75f63228e..c0709152b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -17,6 +17,7 @@ import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -41,7 +42,7 @@ public class LoadingCompletedConsumer { @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index f90963626..d66ef69ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -2,11 +2,16 @@ package com.baiyue.lastleg.inboundplan.pda; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; +import com.baiyue.common.event.EventEnvelope; import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; import jakarta.validation.constraints.Size; import java.io.IOException; @@ -58,6 +63,17 @@ class ReceivingContainerContractTest { } } + @Test + void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException { + Transactional pda = ReceivingContainerService.class + .getMethod("complete", String.class, ReceivingContainerRequests.Action.class) + .getAnnotation(Transactional.class); + Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class) + .getAnnotation(Transactional.class); + assertTrue(pda != null && pda.isolation() == Isolation.READ_COMMITTED); + assertTrue(bridge != null && bridge.isolation() == Isolation.READ_COMMITTED); + } + @Test void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 450762dac..29e680846 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -916,7 +916,7 @@ export class AcceptanceRun { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker'] - const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] + const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 489b9a335..7405b5832 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -80,7 +80,7 @@ test('cross-region normalization keeps existing producers ahead of every phase-t test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')) - assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'\]/) + assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'\]/) }) test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 18c01aa4b..2da55c862 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -138,6 +138,25 @@ async function uploadPhotos(context, token) { return photos } +async function awaitProducerCandidates(context, token, producers) { + const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + if (!expected.length) return false + let response = null + let seen = new Set() + for (let attempt = 1; attempt <= 30; attempt += 1) { + response = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const ready = expected.every((containerNo) => seen.has(containerNo)) + context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready, + `expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS) + return ready +} + function skuFixtures(context) { const boxes = context.createdResources?.orders?.boxTypes const orchestration = context.createdResources?.orchestrationFixtures || {} @@ -301,7 +320,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) - } else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码') + } else if (preferredProducer?.destinationType !== 'OVERSEAS') { + blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + } if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { const pallet = pallets.get(overseasInbound.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { @@ -309,7 +330,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark) - } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } else if (preferredProducer?.destinationType !== 'FBA') { + blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) @@ -356,6 +379,12 @@ export async function run(context) { const matrix = context.createdResources?.phase4InboundProducerFixtures const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) + const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers + if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { + blockerGap(context, '海外待收柜生产矩阵', + '三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库') + return + } for (const [index, producer] of (producers.length ? producers : [null]).entries()) { context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` context.createdResources.phase4OverseasPda.finalStatus = null