fix: serialize phase4 inventory and outbound gates
This commit is contained in:
+7
-6
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+2
-1
@@ -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())
|
||||
|
||||
+16
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user