fix: serialize phase4 inventory and outbound gates

This commit is contained in:
2026-08-24 22:32:37 +08:00
parent 3fbb7653f7
commit 0e73a79479
8 changed files with 69 additions and 20 deletions
@@ -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");
}
}
@@ -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");
});
}
@@ -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);
@@ -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())
@@ -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();
+1 -1
View File
@@ -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],
}]))
@@ -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', () => {
@@ -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