fix(firstleg): finalize loading before customs acceptance
This commit is contained in:
@@ -298,6 +298,12 @@ public class SmartLoadingService {
|
|||||||
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
||||||
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
||||||
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
|
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
|
||||||
|
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId);
|
||||||
|
int pickedPackageCount = pickSnapshot == null ? 0
|
||||||
|
: pickSnapshot.pickedByPlanItem().getOrDefault(itemId, 0);
|
||||||
|
if (request.packageCount() < pickedPackageCount) {
|
||||||
|
throw conflict("计划箱数不能低于已拣货箱数");
|
||||||
|
}
|
||||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||||
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
|
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
|
||||||
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
|
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
@@ -595,6 +596,23 @@ class SmartLoadingServiceTest {
|
|||||||
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
|
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pickedQuantityConflictPrecedesCandidateAvailabilityValidation() {
|
||||||
|
LoadingPlanEntity plan = plan("LOADING", 2);
|
||||||
|
LoadingPlanItemEntity line = item(8, 9, 20, 2);
|
||||||
|
when(repository.plan(1L)).thenReturn(plan);
|
||||||
|
when(repository.item(1L, 8L)).thenReturn(line);
|
||||||
|
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
|
||||||
|
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2)));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.adjust(1L, 8L,
|
||||||
|
new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u"))
|
||||||
|
.hasMessageContaining("不能低于已拣货箱数");
|
||||||
|
|
||||||
|
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
|
||||||
|
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void preservesDownstreamCode() {
|
void preservesDownstreamCode() {
|
||||||
stubAssignment();
|
stubAssignment();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
|||||||
const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300'
|
const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300'
|
||||||
const DEFAULT_USERNAME = 'platform_admin'
|
const DEFAULT_USERNAME = 'platform_admin'
|
||||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
||||||
|
|
||||||
export function normalizeModuleOrder(modules) {
|
export function normalizeModuleOrder(modules) {
|
||||||
const normalized = [...modules]
|
const normalized = [...modules]
|
||||||
@@ -33,8 +33,8 @@ export function normalizeModuleOrder(modules) {
|
|||||||
'phase3-container-loading',
|
'phase3-container-loading',
|
||||||
'phase3-loading',
|
'phase3-loading',
|
||||||
'ocean-bookings',
|
'ocean-bookings',
|
||||||
'phase3-customs',
|
|
||||||
'phase3-loading-finalize',
|
'phase3-loading-finalize',
|
||||||
|
'phase3-customs',
|
||||||
]
|
]
|
||||||
for (const prerequisite of prerequisites) {
|
for (const prerequisite of prerequisites) {
|
||||||
if (!normalized.includes(prerequisite)) {
|
if (!normalized.includes(prerequisite)) {
|
||||||
@@ -98,6 +98,12 @@ export function normalizeModuleOrder(modules) {
|
|||||||
const missing = prerequisites.filter((name) => !normalized.includes(name))
|
const missing = prerequisites.filter((name) => !normalized.includes(name))
|
||||||
if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing)
|
if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing)
|
||||||
}
|
}
|
||||||
|
const customsIndex = normalized.indexOf('phase3-customs')
|
||||||
|
const finalizeIndex = normalized.indexOf('phase3-loading-finalize')
|
||||||
|
if (customsIndex >= 0 && finalizeIndex >= 0 && customsIndex < finalizeIndex) {
|
||||||
|
normalized.splice(finalizeIndex, 1)
|
||||||
|
normalized.splice(normalized.indexOf('phase3-customs'), 0, 'phase3-loading-finalize')
|
||||||
|
}
|
||||||
const loadingIndex = normalized.indexOf('phase3-loading')
|
const loadingIndex = normalized.indexOf('phase3-loading')
|
||||||
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
||||||
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
|
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
|
||||||
@@ -218,7 +224,6 @@ export class AcceptanceRun {
|
|||||||
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestOverseas(method, path, body = undefined, extraHeaders = {}) {
|
async requestOverseas(method, path, body = undefined, extraHeaders = {}) {
|
||||||
const headers = { Accept: 'application/json', ...extraHeaders }
|
const headers = { Accept: 'application/json', ...extraHeaders }
|
||||||
if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}`
|
if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}`
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ test('downstream finalizer uses the frozen truck-coupled complete contract', ()
|
|||||||
'结算递增 inboundVersion 后 final complete 必须使用最新版本',
|
'结算递增 inboundVersion 后 final complete 必须使用最新版本',
|
||||||
'入库单终态 LOADED、版本递增且审计历史存在',
|
'入库单终态 LOADED、版本递增且审计历史存在',
|
||||||
])
|
])
|
||||||
|
assert.match(finalize, /const taskDetailPath = `\$\{taskPath\}\?warehouseId=/)
|
||||||
assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/)
|
assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/)
|
||||||
assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/)
|
assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/)
|
||||||
assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/)
|
assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/)
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ export async function run(context) {
|
|||||||
|
|
||||||
const base = `/api/platform/v1/inbounds/${inboundId}`
|
const base = `/api/platform/v1/inbounds/${inboundId}`
|
||||||
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
|
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
|
||||||
const taskBeforeResponse = await context.request('GET', taskPath)
|
const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
|
||||||
|
const taskBeforeResponse = await context.request('GET', taskDetailPath)
|
||||||
const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS)
|
const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '最终完成前四元身份稳定且任务等待人工确认', taskBeforeResponse,
|
context.assert(MODULE, '最终完成前四元身份稳定且任务等待人工确认', taskBeforeResponse,
|
||||||
id(taskBefore?.id) === taskId && id(taskBefore?.inboundId) === inboundId
|
id(taskBefore?.id) === taskId && id(taskBefore?.inboundId) === inboundId
|
||||||
@@ -105,7 +106,7 @@ export async function run(context) {
|
|||||||
if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') {
|
if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') {
|
||||||
context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false,
|
context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false,
|
||||||
`expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS)
|
`expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS)
|
||||||
const taskAfterFailureResponse = await context.request('GET', taskPath)
|
const taskAfterFailureResponse = await context.request('GET', taskDetailPath)
|
||||||
const taskAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查 PDA 任务', taskAfterFailureResponse, '', OPTIONS)
|
const taskAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查 PDA 任务', taskAfterFailureResponse, '', OPTIONS)
|
||||||
const planAfterFailureResponse = await context.request('GET', planPath)
|
const planAfterFailureResponse = await context.request('GET', planPath)
|
||||||
const planAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查计划', planAfterFailureResponse, '', OPTIONS)
|
const planAfterFailure = context.apiSuccess(MODULE, 'final complete 失败后回查计划', planAfterFailureResponse, '', OPTIONS)
|
||||||
|
|||||||
@@ -508,15 +508,15 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
|
|||||||
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
|
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('default acceptance order verifies loading evidence before finalizing LOADED', () => {
|
test('default acceptance order finalizes LOADED before customs reads fulfillment slices', () => {
|
||||||
const entry = source('../admin-test-env-acceptance.mjs')
|
const entry = source('../admin-test-env-acceptance.mjs')
|
||||||
assertOrder(entry, [
|
assertOrder(entry, [
|
||||||
"'phase3-loading'",
|
"'phase3-loading'",
|
||||||
"'phase3-loading-model-bridge'",
|
"'phase3-loading-model-bridge'",
|
||||||
"'phase3-pda-convergence'",
|
"'phase3-pda-convergence'",
|
||||||
"'phase3-evidence-gate'",
|
"'phase3-evidence-gate'",
|
||||||
"'phase3-customs'",
|
|
||||||
"'phase3-loading-finalize'",
|
"'phase3-loading-finalize'",
|
||||||
|
"'phase3-customs'",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ test('default loading chain carries the three loading-list remarks contract', ()
|
|||||||
assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/)
|
assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('default Admin HTTP entry carries evidence chain through settlement-aware PDA completion', () => {
|
test('default Admin HTTP entry completes settlement before customs consumes loaded slices', () => {
|
||||||
const focused = normalizeModuleOrder([
|
const focused = normalizeModuleOrder([
|
||||||
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
||||||
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
|
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
|
||||||
@@ -120,8 +120,8 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P
|
|||||||
assert.deepEqual(focused, [
|
assert.deepEqual(focused, [
|
||||||
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
||||||
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
|
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
|
||||||
'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs',
|
'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize',
|
||||||
'phase3-loading-finalize',
|
'phase3-customs',
|
||||||
])
|
])
|
||||||
|
|
||||||
const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
|
const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
|
||||||
|
|||||||
Reference in New Issue
Block a user