fix: complete phase4 receiving acceptance chain

This commit is contained in:
2026-08-24 22:14:41 +08:00
parent 8cc4c6f6da
commit 3fbb7653f7
12 changed files with 79 additions and 22 deletions
@@ -461,6 +461,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt;
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
UPDATE ll_receiving_container_session
SET active_operator_key=NULL,active_container_key=NULL
WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
DELIMITER $$
CREATE PROCEDURE assert_receiving_global_locks_migratable()
BEGIN
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.inboundplan.pda.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
@@ -19,7 +21,9 @@ public class ReceivingContainerSession {
private String overseasWarehouseName;
private String operatorKey;
private String operatorName;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeOperatorKey;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String activeContainerKey;
private String status;
private String signPhotos;
@@ -201,10 +201,15 @@ public class ReceivingContainerService {
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = refs(customerId, session.getContainerNo()).stream()
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
@@ -285,10 +290,15 @@ public class ReceivingContainerService {
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = refs(customerId, session.getContainerNo()).stream()
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
@@ -1,7 +1,10 @@
package com.baiyue.lastleg.inboundplan.pda;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
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 org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import jakarta.validation.constraints.Size;
@@ -46,6 +49,15 @@ class ReceivingContainerContractTest {
assertTrue("hasAuthority('lastleg:pda:operate')".equals(permission.value()));
}
@Test
void completedSessionAlwaysPersistsClearedGlobalLockKeys() throws NoSuchFieldException {
for (String fieldName : List.of("activeOperatorKey", "activeContainerKey")) {
TableField field = ReceivingContainerSession.class.getDeclaredField(fieldName)
.getAnnotation(TableField.class);
assertTrue(field != null && field.updateStrategy() == FieldStrategy.ALWAYS, fieldName);
}
}
@Test
void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException {
String schema = resource("db/lastleg.sql").toLowerCase();
@@ -81,6 +93,7 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
assertTrue(unified.contains("signal sqlstate '45000'"));
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)"));
assertTrue(unified.contains("concat('operator:',operator_key)"));
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
@@ -172,11 +172,10 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse);
stubCurrent("FBA", "CONT-A");
InboundPlanCarton carton = carton(1L, "BOX-1");
when(cartons.selectOne(any())).thenReturn(carton);
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(cartons);
verifyNoInteractions(warehouse);
}
+3 -1
View File
@@ -22,7 +22,7 @@ const MODULE_DEPENDENCIES = new Map([
['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']],
['phase3-customs', ['phase3-loading-finalize']],
['phase3-port-schedules', ['ocean-bookings']],
['phase3-outbound-dispatch', ['phase3-customs']],
['phase3-outbound-dispatch', ['phase3-customs', 'phase3-port-schedules', 'service-center-flow']],
['phase3-outbound-aggregate', ['phase3-outbound-dispatch']],
['phase3-dispatch-gate', ['phase3-outbound-dispatch']],
['phase3-waiting-for-loading', ['phase3-outbound-dispatch']],
@@ -870,6 +870,7 @@ export class AcceptanceRun {
tenantId: fixture.tenantId,
orderId: fixture.orderId || this.createdResources.orders?.orderId,
inboundId: fixture.inboundId,
boxCode: fixture.boxCode,
customerRemark: fixtureOrder.customerRemark,
internalRemark: fixtureOrder.internalRemark,
cargoRemark: fixtureOrder.cargoRemark,
@@ -888,6 +889,7 @@ export class AcceptanceRun {
tenantId: incrementalSource.tenantId,
orderId: incrementalSource.orderId,
inboundId: incrementalSource.inboundId,
boxCode: incrementalSource.boxCode,
} : { marker: originalMarker, failed: true }
this.createdResources.orchestrationFixtures = {
...(this.createdResources.orchestrationFixtures || {}),
@@ -365,6 +365,7 @@ export async function run(context) {
containerBookingId,
sourceOrderId: orderId,
sourceInboundId: inboundId,
cartonMark: fixture.cartonMark || null,
destinationType: fixture.destinationType || null,
destinationWarehouseId: id(fixture.destinationWarehouseId),
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
@@ -1180,6 +1180,7 @@ export async function run(context) {
context.createdResources.phase3Loading = {
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
cartonMark: source?.boxCode || source?.cartonMark || null,
destinationType: initial?.summary?.destinationType || source?.destinationType || null,
destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId),
destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null,
@@ -29,7 +29,8 @@ test('focused carrier node execution injects the complete outbound-confirm produ
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
const required = ['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch',
'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules',
'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch',
'phase3-carrier-schedule-queries', 'phase4-carrier-nodes']
for (const module of required) assert.ok(modules.includes(module), module)
for (let index = 1; index < required.length; index += 1) {
@@ -15,8 +15,8 @@ function conflictMessage(response, pattern) { return conflict(response) && patte
function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) }
function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) }
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) {
context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true })
@@ -183,8 +183,8 @@ async function receiveFba(context, token, inbound, pallet) {
return true
}
async function receiveOverseas(context, token, inbound, pallet) {
const cartonMark = context.createdResources?.inboundMeasurement?.boxCode
async function receiveOverseas(context, token, inbound, pallet, producerCartonMark = null) {
const cartonMark = producerCartonMark || context.createdResources?.inboundMeasurement?.boxCode
if (!nonBlank(cartonMark)) {
blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号')
return false
@@ -207,12 +207,12 @@ async function receiveOverseas(context, token, inbound, pallet) {
return true
}
async function runReceiving(context, token, secondaryToken, merchantToken) {
if (context.overseasToken) {
async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
if (runAuthorizationProbes && context.overseasToken) {
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
}
if (merchantToken) {
if (runAuthorizationProbes && merchantToken) {
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken)
context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
}
@@ -229,8 +229,16 @@ async function runReceiving(context, token, secondaryToken, merchantToken) {
blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response)
return
}
const preferredContainer = context.createdResources?.phase3Loading?.containerNo
const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0]
const preferredContainer = id(preferredProducer?.containerNo)
|| context.createdResources?.phase3Loading?.containerNo
const candidate = preferredContainer
? candidates.data.find((item) => item.containerNo === preferredContainer)
: candidates.data[0]
if (!candidate) {
blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`,
'三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response)
return
}
const containerNo = id(candidate?.containerNo)
context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response,
nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0,
@@ -300,7 +308,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) {
inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode,
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
}, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i))
await receiveOverseas(context, token, overseasInbound, pallet)
await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark)
} else blockerGap(context, '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) {
@@ -321,7 +329,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) {
export async function run(context) {
context.createdResources.phase4OverseasPda = {
marker: context.marker, containerNo: null, customerId: null,
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [],
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
overseasWarehouseId: null, overseasWarehouseName: null,
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
}
@@ -345,8 +353,20 @@ export async function run(context) {
blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁')
return
}
await runReceiving(context, primary.token, secondary.token, merchantToken)
const matrix = context.createdResources?.phase4InboundProducerFixtures
const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]]
.filter((item) => nonBlank(item?.containerNo))
for (const [index, producer] of (producers.length ? producers : [null]).entries()) {
context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}`
context.createdResources.phase4OverseasPda.finalStatus = null
await runReceiving(context, primary.token, secondary.token,
index === 0 ? merchantToken : null, producer, index === 0)
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null)
}
}
} finally {
delete context.pdaContainerSuffix
await disablePicker(context, secondary, 2)
await disablePicker(context, primary, 1)
}
@@ -42,7 +42,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] })
if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }]))
if (requestPath.includes('/relabel-applications/RL-1?')) return response({ application: { relabelNo: 'RL-1' }, items: [], history: [] })
if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ id: '50001', sourceOrderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }]))
if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ lineId: '50002', planId: '50001', orderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }]))
if (requestPath.includes('/inbound-plans/IP-1?')) return response({ planNo: 'IP-1', sourceOrderId: '30001', lines: [], pallets: [], allocations: [] })
if (requestPath.includes('/inventory/summary?')) return response({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 })
if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }]))
@@ -145,7 +145,9 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径',
for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden)
assert.match(source, /requestId=/)
assert.match(source, /traceId=/)
assert.match(source, /longId\(plan\.sourceOrderId\)/)
for (const field of ['lineId', 'planId', 'orderId']) {
assert.match(source, new RegExp(`longId\\(plan\\.${field}\\)`))
}
assert.match(source, /id\(pallet\.customerId\) === '0'/)
assert.match(source, /创建本轮换标单/)
assert.match(source, /换标单同幂等键不同请求返回A0409/)
@@ -107,8 +107,9 @@ export async function run(context) {
const plan = records(plans.data)[0]
if (plan) {
context.assert(MODULE, '入仓计划关键字段正确', plans.response,
longId(plan.id) && longId(plan.sourceOrderId) && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status),
`id=${id(plan.id) || 'missing'}, sourceOrderId=${id(plan.sourceOrderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS)
longId(plan.lineId) && longId(plan.planId) && longId(plan.orderId)
&& nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status),
`lineId=${id(plan.lineId) || 'missing'}, planId=${id(plan.planId) || 'missing'}, orderId=${id(plan.orderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS)
const detail = await overseasGet(context, '查询入仓计划详情', query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}`, { customerId }))
context.assert(MODULE, '入仓计划详情对应列表记录', detail.response,
detail.data?.planNo === plan.planNo && longId(detail.data?.sourceOrderId)