Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs
This commit is contained in:
2026-09-04 15:41:18 +08:00
30 changed files with 762 additions and 147 deletions
+1
View File
@@ -15,6 +15,7 @@
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` |
| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 |
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写测试环境,必须有当前轮授权 |
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
+13
View File
@@ -63,6 +63,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 分类 | 入口 | 用途与副作用 |
|---|---|---|
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
| 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 |
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
@@ -125,6 +126,18 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
## 订单测量与连续库位扩展矩阵
部署完成后,可用独立入口串行执行订单创建、审核建入库单、称重确认、库位指引、删除释放、补传复用、测量提交/审核至 `WAIT_SPLIT`。默认固定“义乌仓”并执行 5 个确定性场景:单图单槽、单图两预留槽、实测体积跨档、两图逐次扩容、小体积订单三图 `1→2→3` 连续扩容。每票使用独立订单、入库单、库位块和 reservation,最后额外验证跨票不串数据。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/pallet-location-test-env-acceptance.mjs
```
只回归部分场景时使用 `--scenarios single-image-single-slot,measured-volume-growth`;仓库可用 `--origin-warehouse-name <名称>` 覆盖。汇总默认写入 `reports/pallet-location-http-matrix-*.json`,每票完整证据写入相邻的 `*-details/` 目录。任一场景失败后仍继续执行剩余场景,最终以非零退出码和汇总报告统一暴露失败。
该入口只复用正式 HTTP 验收模块,不执行 SQL、不部署服务,也不会为了制造容量不足而批量停用普通库位;普通库位自然不足时记录是否落到共享 `A0`。共享 Warehouse 故障不做停服注入,其“不回滚测量主流程”语义由服务单元测试覆盖。真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
## 固定客户待审核订单
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
@@ -111,12 +111,10 @@ export function portScheduleFieldGaps(record) {
}
export function truckAppointmentFieldGaps(record) {
return gaps(record, {
const result = gaps(record, {
id: isPositiveId,
appointmentNo: isNonBlank,
oceanBookingId: isPositiveId,
containerBookingId: isPositiveId,
loadingPlanId: (value) => value == null || isPositiveId(value),
warehouseId: (value) => value == null || isPositiveId(value),
plannedLoadingAt: isIsoDateTime,
status: isNonBlank,
@@ -126,6 +124,10 @@ export function truckAppointmentFieldGaps(record) {
currency: (value) => value === 'RMB' || value === 'USD',
attachmentCount: isNonNegativeNumber,
})
for (const field of ['containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers']) {
if (Object.hasOwn(record || {}, field)) result.push(`forbidden:${field}`)
}
return result
}
export function customsCaseFieldGaps(record) {
@@ -54,7 +54,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => {
'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'],
'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'],
'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'],
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'],
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'oceanBookingNo', 'billOfLadingNo', 'warehouseName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'],
'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
}
for (const [relative, fields] of Object.entries(expectations)) {
@@ -87,7 +87,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
[outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'],
]
for (const [validator, sample, options, missing] of samples) {
@@ -97,6 +97,11 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
target[missing] = ''
assert.ok(validator(invalid, options).includes(missing))
}
assert.ok(truckAppointmentFieldGaps({
id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, warehouseId: ids.id,
carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB',
plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0, containerNo: 'MSCU1234567',
}).includes('forbidden:containerNo'))
})
test('字段断言接入正式生产模块和默认全量入口', () => {
@@ -38,16 +38,13 @@ test('deduplication uses stable event identity and never identical display text'
assert.deepEqual(rows.map((row) => row.historyId), ['1', '3', '4', '5'])
})
test('backend never appends a generic STATUS_UPDATE and push replay is read-only', () => {
test('backend never appends a generic STATUS_UPDATE and current operation replay is read-only', () => {
assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s)
assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/)
assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/)
const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()')
const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10)
const replayBlock = smartLoadingTest.slice(replayStart, replayEnd)
assert.notEqual(replayStart, -1)
assert.match(replayBlock, /never\(\)\)\.insert/)
assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/)
assert.match(smartLoading, /private Plan replay\([\s\S]{0,900}sameRequestHash/)
assert.match(smartLoadingTest, /void replaysBeforeVersionCheck\(\)/)
assert.match(smartLoadingTest, /void exactWarehouseRemarkReplayIsReadOnly\(\)/)
assert.match(smartLoadingTest, /replaysBeforeVersionCheck[\s\S]{0,1200}verify\(repository, never\(\)\)\.candidate/)
assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateWarehouseRemark/)
})
test('operationId is projected end-to-end for identity-based frontend deduplication', () => {
@@ -37,7 +37,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(acceptance, /merchant-username和merchant-password必须同时提供/)
assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/)
assert.match(acceptance, /wait-split-count必须是1-100的整数/)
assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/)
assert.match(acceptance, /fixtureModules = new Set\(\['merchant-qualification-fixture', 'orders', 'inbound-measurement',[\s\S]{0,180}'coload-groups'[\s\S]{0,180}'firstleg-container-fee-allocation'/)
assert.match(acceptance, /批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块/)
assert.match(acceptance, /createAdditionalWaitSplitFixtures/)
assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/)
assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/)
@@ -91,9 +91,10 @@ test('a different operationId is rejected while the completion command is proces
test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => {
assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/)
assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/)
assert.match(settlementTests, /reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback/)
assert.match(settlementTests, /warehouse\.settlementTarget\("internal-token", 31L, "PDA-SETTLE-1-31"\)/)
assert.match(settlementTests, /targetBusinessId\(\) == 999L/)
assert.match(settlementTests, /repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain/)
assert.match(settlementTests, /splits\.appliedBatch\(10001L, "PDA-SPLIT-1-31"\)/)
assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.transferBlock/)
assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.releaseBlock/)
assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/)
})
@@ -64,7 +64,7 @@ test('active overview photos download through controlled paths while void histor
]) assert.equal(controlledPhotoDownload(path, response), false)
})
test('truck attachment API and Admin upload-reference-delete flow are frozen', () => {
test('truck attachment API reference flow and Admin upload-delete flow are frozen', () => {
assert.match(truckApi, /referenceTruckAppointmentAttachment/)
assert.match(truckApi, /uploadTruckAppointmentAttachment/)
assert.match(truckApi, /deleteTruckAppointmentAttachment/)
@@ -91,7 +91,8 @@ test('existing container number replacement keeps container plan and PDA identit
})
test('PDA task opens only after no-number push, release, four-field link and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
assert.match(containerLoadingAcceptance, /分配后自动建立预装关联且计划可读/)
assert.match(containerLoadingAcceptance, /无号 push 后预装清单可见/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/)
@@ -30,8 +30,9 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap
const convergence = read('phase3-pda-convergence.mjs')
const dispatch = read('phase3-outbound-dispatch.mjs')
const aggregate = read('phase3-outbound-aggregate.mjs')
assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/)
assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/)
assert.match(loading, /增量后旧 inventory token 明确 A0409 并要求刷新[\s\S]{0,500}codeOf\(staleIncrementBatch\) === 'A0409'/)
assert.match(loading, /增量旧 token 冲突后 plan\/task\/items 零写入/)
assert.match(convergence, /fixture\?\.dynamicIncrementVerified !== true[\s\S]{0,500}不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护/)
assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/)
assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
assert.match(dispatch, /'CUSTOMS_CLEARANCE'/)
@@ -14,11 +14,13 @@ test('phase3 loading fixture identifiers are deterministic and distinct across m
assert.ok(`P3B-${primary}`.length <= 64)
})
test('phase3 loading uses the collision-safe marker suffix for inventory mutation locations', async () => {
test('phase3 loading uses the collision-safe marker suffix for generated business identifiers', async () => {
const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('./phase3-loading.mjs', import.meta.url), 'utf8'))
assert.match(source, /const mutationSuffix = markerSuffix\(context\.marker\)/)
assert.doesNotMatch(source, /mutationSuffix = context\.marker\.replace\(\/\\D\/g/)
assert.match(source, /if \(!targetLocationId\)[\s\S]+库存变化fixture创建失败后停止旧snapshot提交[\s\S]+return/)
assert.match(source, /if \(!reboundValid\)[\s\S]+库存换绑未成功后停止旧snapshot提交[\s\S]+return/)
assert.match(source, /const suffix = markerSuffix\(context\.marker\)/)
assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/)
assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/)
assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/)
assert.match(source, /增量后旧 inventory token 明确 A0409 并要求刷新/)
assert.match(source, /增量旧 token 冲突后 plan\/task\/items 零写入/)
})
@@ -1334,6 +1334,20 @@ export async function run(context) {
&& authoritativeRemaining === authoritativeTotal - assignedPackageCount,
`totalPackageCount=${reassignedPlanItem?.totalPackageCount ?? 'missing'}, packageCount=${reassignedPlanItem?.packageCount ?? 'missing'}, remainingPackageCount=${reassignedPlanItem?.remainingPackageCount ?? 'missing'}`,
OPTIONS)
const assignedByBookingResponse = await context.request('GET',
`${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`)
const assignedByBooking = context.apiSuccess(MODULE, '计划已有明细后按柜次读取智能装柜详情',
assignedByBookingResponse, '', OPTIONS)
const assignedByBookingItem = assignedByBooking?.items?.find?.((item) =>
id(item?.id) === id(reassignedPlanItem?.id))
context.assert(MODULE, '按柜次详情返回同一计划及权威箱数且数量守恒', assignedByBookingResponse,
id(assignedByBooking?.id) === loadingPlanId
&& Number(assignedByBooking?.version) === Number(reassignedPlan?.version)
&& Number(assignedByBookingItem?.packageCount) === assignedPackageCount
&& Number(assignedByBookingItem?.totalPackageCount) === authoritativeTotal
&& Number(assignedByBookingItem?.remainingPackageCount) === authoritativeRemaining,
`loadingPlanId=${id(assignedByBooking?.id) || 'missing'}, packageCount=${assignedByBookingItem?.packageCount ?? 'missing'}, totalPackageCount=${assignedByBookingItem?.totalPackageCount ?? 'missing'}, remainingPackageCount=${assignedByBookingItem?.remainingPackageCount ?? 'missing'}`,
OPTIONS)
if (reassignedPlanItem?.id && Number.isInteger(authoritativeTotal) && authoritativeTotal > 0) {
const overAuthorityResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, {
@@ -1341,8 +1355,8 @@ export async function run(context) {
expectedItemVersion: Number(reassignedPlanItem.version),
operationId: `${context.marker}-dynamic-over-authority`.slice(0, 128),
})
context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0400', overAuthorityResponse,
overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0400',
context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0422', overAuthorityResponse,
overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0422',
responseEvidence(overAuthorityResponse), OPTIONS)
const afterOverAuthorityResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterOverAuthority = context.apiSuccess(MODULE, '超权威总箱数失败后回查计划',
@@ -1377,8 +1391,44 @@ export async function run(context) {
&& Number(adjustedItem?.packageCount) === 2 && adjustedItem?.warehouseRemark === reassignedPlanItem?.warehouseRemark
&& id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
effectiveDynamicPlan = adjustedPlan
reassignedPlanItem = adjustedItem
const decreaseResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(adjustedItem?.id))}`, {
packageCount: 1, expectedPlanVersion: Number(adjustedPlan?.version),
expectedItemVersion: Number(adjustedItem?.version),
operationId: `${context.marker}-dynamic-adjust-down`.slice(0, 128),
})
const decreasedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许向下调整数量',
decreaseResponse, '', OPTIONS)
const decreasedItem = decreasedPlan?.items?.find?.((item) => id(item?.id) === id(adjustedItem?.id))
context.assert(MODULE, '向下调整后版本递增且权威总数与剩余数同步', decreaseResponse,
Number(decreasedPlan?.version) > Number(adjustedPlan?.version)
&& Number(decreasedItem?.version) > Number(adjustedItem?.version)
&& Number(decreasedItem?.packageCount) === 1
&& Number(decreasedItem?.totalPackageCount) === authoritativeTotal
&& Number(decreasedItem?.remainingPackageCount) === authoritativeTotal - 1
&& id(decreasedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${decreasedPlan?.version ?? 'missing'}, itemVersion=${decreasedItem?.version ?? 'missing'}, packageCount=${decreasedItem?.packageCount ?? 'missing'}, remainingPackageCount=${decreasedItem?.remainingPackageCount ?? 'missing'}`,
OPTIONS)
const restoreAdjustedResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(decreasedItem?.id))}`, {
packageCount: 2, expectedPlanVersion: Number(decreasedPlan?.version),
expectedItemVersion: Number(decreasedItem?.version),
operationId: `${context.marker}-dynamic-adjust-restore`.slice(0, 128),
})
const restoredAdjustedPlan = context.apiSuccess(MODULE, '向下调整验证后恢复两箱供已拣下限校验',
restoreAdjustedResponse, '', OPTIONS)
const restoredAdjustedItem = restoredAdjustedPlan?.items?.find?.((item) => id(item?.id) === id(decreasedItem?.id))
context.assert(MODULE, '恢复后版本递增且数量回到两箱', restoreAdjustedResponse,
Number(restoredAdjustedPlan?.version) > Number(decreasedPlan?.version)
&& Number(restoredAdjustedItem?.version) > Number(decreasedItem?.version)
&& Number(restoredAdjustedItem?.packageCount) === 2
&& Number(restoredAdjustedItem?.totalPackageCount) === authoritativeTotal
&& Number(restoredAdjustedItem?.remainingPackageCount) === authoritativeTotal - 2
&& id(restoredAdjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${restoredAdjustedPlan?.version ?? 'missing'}, itemVersion=${restoredAdjustedItem?.version ?? 'missing'}, packageCount=${restoredAdjustedItem?.packageCount ?? 'missing'}`,
OPTIONS)
effectiveDynamicPlan = restoredAdjustedPlan
reassignedPlanItem = restoredAdjustedItem
}
const refreshedDynamicTaskResponse = await context.request('GET', taskDetailPath)
const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS)
@@ -1407,7 +1457,7 @@ export async function run(context) {
const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS)
const reducePickedResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
packageCount: Math.max(1, Number(pickedDynamicPlanItem?.minPackageCount) - 1),
packageCount: Number(pickedDynamicPlanItem?.minPackageCount) - 1,
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128),
})
@@ -18,7 +18,8 @@ test('each remainder uses a simulation linked to a released booking requirement'
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/)
const releaseStart = source.indexOf('const releaseResponse')
const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart))
assert.match(releaseBlock, /expectedVersion: Number\(bookingCreated\?\.oceanBooking\?\.version\)/)
assert.match(source, /bookingDocumentForm\.append\('expectedVersion', String\(Number\(bookingCreated\?\.oceanBooking\?\.version\)\)\)/)
assert.match(releaseBlock, /expectedVersion: releaseVersion/)
assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/)
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
@@ -69,11 +69,11 @@ test('records LOADED and clears the reason only after a real LOADED reread', ()
assert.equal(result.notCleanedReason, null)
})
test('container loading follows allocation sync, claim, real pick, completed sequence', () => {
test('container loading follows push, claim, real pick, completed sequence', () => {
const value = source('./phase3-container-loading.mjs')
assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/)
assertOrder(value, [
"synchronizedPlan?.status === 'EDITING'",
"pushed?.status === 'EDITING'",
"claimedPlan?.status === 'LOADING'",
"const pickResponse = await request",
"const completeResponse = await request",
@@ -88,9 +88,10 @@ test('container loading follows allocation sync, claim, real pick, completed seq
test('bridge loading confirms preload before creating and claiming the PDA task', () => {
const value = source('./phase3-loading.mjs')
assertOrder(value, [
'读取柜次创建时原子生成的智能装柜计划',
'分配同步建立 bridge 关联',
'无真实柜号时 PDA 查询明确 A0404',
'分配后自动建立预装关联且柜次计划可读',
'无号推送后预装清单正式可见',
'无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'无号且未关联订舱仍无 PDA 任务',
'三期订舱 RELEASED 冻结运输快照',
'模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号',
@@ -111,10 +112,10 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.doesNotMatch(value.slice(value.indexOf('分配同步建立 bridge 关联'), value.indexOf('const linkResponse')),
assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且柜次计划可读'), value.indexOf('const linkResponse')),
/\/transport/)
assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.doesNotMatch(value, /push-preload|无真实柜号拒绝推送柜次计划|PDA complete后补确认/)
assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/)
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
@@ -159,13 +160,24 @@ test('PDA completion production and HTTP chain never call or gate on truck appoi
assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/)
})
test('allocation synchronizes the complete pre-PDA snapshot without a push protocol', () => {
const value = source('./phase3-loading.mjs')
assert.match(value, /plans\/by-booking\/\$\{encodeURIComponent\(containerBookingId\)\}/)
assert.match(value, /分配同步建立 bridge 关联/)
assert.match(value, /分配事务完成态计划与 bridge 一致且保持 EDITING/)
assert.match(value, /Number\(synchronizedPlan\?\.version\) === Number\(link\?\.loadingPlanVersion\)/)
assert.doesNotMatch(value, /push-preload|pushRequest|projectPushPreloadStability|diffPushPreloadStability/)
test('automatic preload bridge repeated reads preserve the complete pre-PDA snapshot', () => {
for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) {
const value = source(file)
assert.match(value, /const pushRequest = Object\.freeze\(\{ path: `\$\{(?:smartLoadingBase|SMART)\}\/plans\/\$\{(?:loadingPlanId|planId)\}` \}\)/)
assert.equal((value.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.match(value, /push exact replay[\s\S]*零漂移/)
assert.match(value, /自动关联计划重复读取稳定[\s\S]*自动关联计划再次读取稳定/)
assert.match(value, /重放后回查柜次计划[\s\S]*重放后回查预装历史[\s\S]*重放后回查 bridge/)
assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/)
assert.match(value, /projectPushPreloadStability/)
assert.match(value, /diffPushPreloadStability/)
assert.match(value, /首次 push 后读取事务完成态柜次计划/)
assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/)
assert.match(value, /plan: postPushPlan, detail:/)
assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/)
assert.doesNotMatch(value, /\/plans\/[^'`]*\/push-preload/)
assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/)
}
})
test('push stability projection sorts unordered facts and reports persisted field drift', () => {
@@ -204,13 +216,15 @@ test('push stability projection sorts unordered facts and reports persisted fiel
])
})
test('allocation orchestration is transactional and binds the preload before PDA creation', () => {
test('assignment transaction establishes preload and bridge before PDA synchronization', () => {
const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const assign = service.slice(service.indexOf('private Plan assign('), service.indexOf('public Plan assignInbound'))
assert.match(service, /@Transactional[\s\S]*public Plan assign/)
assert.match(assign, /ensurePendingForLoadingAllocation[\s\S]*loadingModelBridge\.bindIncrementalAllocation/)
assert.match(assign, /loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/)
assert.doesNotMatch(source('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java'), /push-preload/)
const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java')
const assign = service.slice(service.indexOf('public Plan assign('), service.indexOf('public Plan assignInbound('))
assert.match(service, /@Transactional[\s\S]{0,300}public Plan assign\(/)
assert.match(assign, /splitPreloadService\.ensure(?:Pending|ForRunning)[\s\S]*touch\(plan[\s\S]*history\([\s\S]*loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/)
assert.match(serviceTest, /addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus/)
assert.match(serviceTest, /inOrder\(loadingPickService, splitPreloadService, repository, loadingModelBridge\)/)
assert.match(serviceTest, /order\.verify\(loadingModelBridge\)\.bindIncrementalAllocation/)
})
test('finalize rechecks the same unified order detail and list as LOADED', () => {
@@ -261,9 +275,9 @@ test('container loading arranges a simulation before linking a released booking
assertOrder(value, [
"const simulationResponse = await request(context, 'POST', CONTAINERS",
"const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
"const planResponse = await context.request('GET', `${SMART}/plans/by-booking/",
"const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/",
"const assignResponse = await request",
'分配同步建立 bridge',
'分配后自动建立预装关联且计划可读',
"const bookingCreate = await request",
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
])
@@ -291,10 +305,10 @@ test('container loading arranges a simulation before linking a released booking
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.doesNotMatch(value.slice(value.indexOf('分配同步建立 bridge'), value.indexOf('const linkResponse')),
assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且计划可读'), value.indexOf('const linkResponse')),
/\/transport/)
assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(value, /分配同步建立 bridge/)
assert.match(value, /分配后自动建立预装关联且计划可读/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(value, /confirmationStatus === 'CONFIRMED'/)
@@ -365,7 +379,6 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch
const value = source('./phase3-container-loading.mjs')
assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/)
assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/)
assert.match(value, /codeOf\(foreignAssignResponse\) === 'A0422'/)
})
test('manual cross-channel loading fixture releases the inbound before the primary flow', () => {
@@ -386,8 +399,8 @@ test('container loading permits manual cross-channel allocation and keeps stable
'手工装柜允许两个真实有效渠道之间跨渠道分配',
'跨渠道分配验收后释放入仓码供主流程继续',
'人工分配本轮候选货物',
'分配同步建立 bridge',
'无真实柜号时 PDA 查询 A0404',
'分配后自动建立预装关联且计划可读',
'无号 push 成功后 PDA 查询 A0404',
'无号且未关联订舱不生成 PDA 任务',
'订舱放舱为 RELEASED 且不预生成真实货柜',
'首次关联 RELEASED 订舱需求并补真实柜号',
@@ -419,21 +432,22 @@ test('container loading permits manual cross-channel allocation and keeps stable
assert.doesNotMatch(batch, /containerNo:/)
})
test('preload and loading versions use immediate allocation and claim baselines', () => {
test('preload and loading versions use immediate baselines and stable automatic bridge reads', () => {
const container = source('./phase3-container-loading.mjs')
assert.match(container, /plans\/by-booking\/\$\{encodeURIComponent\(containerId\)\}/)
assert.match(container, /分配同步建立 bridge/)
assert.doesNotMatch(container, /push-preload|pushRequest/)
assert.match(container, /const pushRequest = Object\.freeze\(\{ path: `\$\{SMART\}\/plans\/\$\{planId\}` \}\)/)
assert.equal((container.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.match(container, /自动关联保持 EDITING 且无需额外推送版本/)
assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/)
assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
const loading = source('./phase3-loading.mjs')
assert.match(loading, /plans\/by-booking\/\$\{encodeURIComponent\(containerBookingId\)\}/)
assert.match(loading, /分配事务完成态计划与 bridge 一致且保持 EDITING/)
assert.doesNotMatch(loading, /push-preload|pushRequest/)
assert.match(loading, /const pushRequest = Object\.freeze\(\{ path: `\$\{smartLoadingBase\}\/plans\/\$\{loadingPlanId\}` \}\)/)
assert.equal((loading.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.match(loading, /自动关联保持 EDITING、身份稳定且无需额外推送版本/)
assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调订舱代理'/)
assert.match(loading, /category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200/)
assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/)
assert.match(loading, /category=BOOKING&page=1&size=200/)
assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/)
assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/)
assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/)
@@ -486,10 +500,10 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au
for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) {
assert.match(loading, new RegExp(key))
}
assert.match(loading, /expectedBatchOperationIds = dropshipWholePallet\s*\? \[batchKey\]\s*:\s*\[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/)
assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/)
assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/)
assert.match(convergence, /expectedBatchCount = fixture\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP' \? 1 : 4/)
assert.match(convergence, /expectedBatchOperationIds\.length === expectedBatchCount/)
assert.match(convergence, /expectedBatchOperationIds\.length === 4/)
assert.doesNotMatch(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified/)
})
test('phase3 loading proves mixed-line rollback across task, all items and operations', () => {
@@ -513,18 +527,19 @@ test('phase3 loading proves stale inventory zero-write and defers final completi
assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/)
assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/)
assert.match(loading, /超权威总箱数失败后计划和明细零写入/)
assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/)
assert.match(loading, /taskItemsSignature\(taskAfterStale\) === taskItemsSignature\(incrementedTask\)/)
assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/)
assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/)
assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2)
assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/)
assertOrder(loading, [
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
'库存变化后刷新 PDA 实时库存快照',
'原子提交柜次 PDA 整批拣货',
'PDA 原子批次同参同键重放保持待人工确认',
'回查下游门禁前原子 Batch 审计',
'最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING',
'LOADING 阶段向同一 PDA task 增加入库单',
'增量后旧 inventory token 明确 A0409 并要求刷新',
'增量旧 token 冲突后 plan/task/items 零写入',
'重新增量后刷新 inventory snapshot',
'刷新后完成动态新增项拣货',
'报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING',
])
assert.doesNotMatch(loading, /complete-before-downstream/)
@@ -575,26 +590,18 @@ test('phase3 loading fixture paginates A0 candidates and refuses mass location m
const orders = source('./orders.mjs')
assertOrder(entry, [
'disableAvailableLocationsForA0(warehouseId, requiredPalletCount)',
'this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow',
'createPhase3FreshMeasurementPallet(warehouseId)',
'this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true',
"executeModule('inbound-measurement-phase3-loading-fixture'",
'restoreAvailableLocationsAfterA0(warehouseId, isolation)',
])
assert.match(entry, /page=\$\{pageNumber\}&size=\$\{A0_LOCATION_PAGE_SIZE\}/)
assert.match(entry, /A0_MAX_LOCATION_MUTATIONS = 8/)
assert.match(entry, /A0 大仓保护:不批量停用普通库位/)
assert.match(entry, /创建本轮库存快照专用称重托盘/)
assert.match(entry, /\/api\/platform\/v1\/pallets\?tenantId=0/)
assert.match(entry, /measurementPalletOverride/)
assert.doesNotMatch(entry, /internal\/warehouse.*inventory-snapshot-a0/)
assert.match(entry, /remainingNormal\.length < isolation\.requiredSlots/)
assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/)
assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/)
assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/)
assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/)
const loading = source('./phase3-loading.mjs')
assert.match(loading, /context\.context\?\.phase3LoadingRequireSharedOverflow === true/)
assert.match(loading, /附加生产者不重复要求 A0 库存快照变更专项/)
})
test('customs reuses the loading chain released booking without pre-completing truck', () => {
@@ -602,8 +609,7 @@ test('customs reuses the loading chain released booking without pre-completing t
assertOrder(value, [
'selectCustomsFlow(context.createdResources)',
'if (!await ensureReleasedOceanBooking(context, container)) return',
'if (!await verifyContainerOperationalDocuments(context, containerBookingId))',
'const customs = await completeContainerCustoms(context, loadingPlanId)',
'const slicesPath =',
])
assert.match(value, /const oceanBookingId = id\(container\?\.oceanBookingId\)/)
assert.match(value, /ocean-bookings\/\$\{encodeURIComponent\(oceanBookingId\)\}/)
@@ -611,10 +617,11 @@ test('customs reuses the loading chain released booking without pre-completing t
assert.doesNotMatch(value, /oceanBookings\.run\(context\)/)
assert.doesNotMatch(value, /serviceCenter\.run\(context\)/)
assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/)
assert.match(value, /PLATFORM_CUSTOMS = '\/api\/platform\/v1\/customs\/loading-plans'/)
assert.match(value, /complete-all-buy/)
assert.match(value, /finalStatus: 'COMPLETED'/)
assert.doesNotMatch(value, /checklist-versions|submission-batches|category: 'CUSTOMS'/)
assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
assert.match(value, /category: 'CUSTOMS'/)
assert.match(value, /context\.createdResources\.phase3CustomsSupplier/)
assert.match(value, /const batchPayload = \{ customsAgentId,/)
assert.doesNotMatch(value, /customsAgentName: `代理-/)
})
test('downstream container consumers prefer the phase3Loading fixture', () => {
@@ -626,13 +633,11 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/)
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/)
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/)
assert.match(serviceCenter, /oceanBookingId: ocean\.id/)
assert.match(serviceCenter, /FORBIDDEN_TRUCK_FIELDS = \['customerId', 'customerName', 'containerBookingId', 'loadingPlanId'/)
assert.match(serviceCenter, /bookingLevel\(payload\)/)
assert.match(serviceCenter, /bookingLevel\(appointment\)/)
assert.doesNotMatch(serviceCenter, /containerBookingId:\s*linkContainer|loadingPlanId:\s*linkContainer/)
const servicePayload = serviceCenter.slice(serviceCenter.indexOf('const payload = {'),
serviceCenter.indexOf('const response =', serviceCenter.indexOf('const payload = {')))
assert.doesNotMatch(servicePayload, /containerBookingId:|loadingPlanId:/)
assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/)
assert.doesNotMatch(serviceCenter, /customerId:|customerName:/)
assert.doesNotMatch(serviceCenter, /链路承运商-/)
@@ -644,10 +649,6 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/)
assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/)
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
assert.match(schedules, /outbound-dispatches/)
assert.match(schedules, /\$\{OUTBOUND\}\/\$\{encodeURIComponent\(knownAllocation\)\}\/gate/)
assert.match(schedules, /item\?\.code !== 'SAILING_SCHEDULE'/)
assert.doesNotMatch(schedules, /\/check-gate\?|find\?\.\(\(item\) => item\?\.code === 'SAILING_SCHEDULE'/)
})
test('default acceptance order finalizes LOADED before customs reads fulfillment slices', () => {
@@ -686,7 +687,7 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /module === 'phase3-loading'/)
assert.match(entry, /preparePhase3LoadingFixture\(\)/)
assert.match(entry, /preparePhase3LoadingFixture\('', requireSharedOverflow\)/)
assert.match(entry, /orders-phase3-loading-fixture/)
assert.match(entry, /inbound-measurement-phase3-loading-fixture/)
assert.match(entry, /marker: originalMarker/)
@@ -706,8 +707,7 @@ test('container fixture isolates order idempotency while publishing the run mark
test('measurement fixture polls asynchronous completion before exporting loading identity', () => {
const measurement = source('./inbound-measurement.mjs')
assert.match(measurement, /waitForCompletedMeasurement/)
assert.match(measurement, /expectedStatuses\.includes\(measurement\?\.processStatus\)/)
assert.match(measurement, /completeDropshipPalletization/)
assert.match(measurement, /measurement\?\.processStatus === 'WAIT_SPLIT'/)
assert.match(measurement, /setTimeout\(resolve, 1_000\)/)
})
@@ -773,8 +773,6 @@ test('loaded boundary acceptance proves evidence and transport immutability with
assert.match(finalize, /evidenceSignature\(afterEvidence\) === immutableEvidenceSignature/)
assert.match(finalize, /afterHistorySignature === historySignature/)
assert.match(finalize, /afterPlanSignature === immutablePlanSignature/)
assert.match(finalize, /warehouseId: id\(fixture\.warehouseId\)/)
assert.doesNotMatch(finalize, /warehouseId: Number\(fixture\.warehouseId\)/)
})
test('finalize publishes a current-marker producer correlation for cross-region verification', () => {
@@ -16,19 +16,17 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null
const context = {
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
async request(method, requestPath) {
calls.push({ channel: 'domestic', method, path: requestPath })
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants))
async request(method, requestPath, body) {
calls.push({ channel: 'domestic', method, path: requestPath, body })
if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants))
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', warehouseType: 'OVERSEAS', status: 'ACTIVE' } })
if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } })
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return voidResponse()
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
throw new Error(`Unexpected domestic path: ${requestPath}`)
},
async requestOverseas(method, requestPath, body) {
calls.push({ channel: 'overseas', method, path: requestPath, body })
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() }
@@ -56,8 +54,6 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
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' }]))
if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }])
if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }]))
if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } })
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null
@@ -88,10 +84,11 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
const context = contextFixture()
return run(context).then(() => {
assert.ok(context.calls.length >= 12)
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(call.method)))
const domesticCalls = context.calls.filter((call) => call.channel === 'domestic')
assert.ok(domesticCalls.length >= 1)
assert.ok(domesticCalls.every((call) => call.method === 'GET'))
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method)))
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3)
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?')))
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?')))
assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false)
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
const writes = context.calls.filter((call) => call.method !== 'GET')
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
@@ -116,8 +113,9 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/locations?', '/pallets?']) {
assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken)
}
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?')), '/warehouse-admin?')
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) {
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?')))
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?')))
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/pallets/70001']) {
assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken)
}
for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) {
@@ -16,12 +16,18 @@ test('measurement HTTP acceptance preserves whole-ticket slot identities across
assert.match(measurement, /asId\(current\.locationId\) === asId\(prior\.locationId\)/)
})
test('smart-loading HTTP acceptance uses authoritative totals and proves over-allocation zero drift', () => {
test('smart-loading HTTP acceptance covers by-booking reads and bidirectional quantity changes', () => {
const loading = source('./phase3-loading.mjs')
assert.match(loading, /计划详情由服务端返回权威总箱数和剩余箱数/)
assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/)
assert.match(loading, /计划已有明细后按柜次读取智能装柜详情/)
assert.match(loading, /按柜次详情返回同一计划及权威箱数且数量守恒/)
assert.match(loading, /packageCount: authoritativeTotal \+ 1/)
assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/)
assert.match(loading, /调整箱数超过服务端权威总箱数返回A0422/)
assert.match(loading, /未拣动态增量明细允许向下调整数量/)
assert.match(loading, /Number\(decreasedItem\?\.remainingPackageCount\) === authoritativeTotal - 1/)
assert.match(loading, /向下调整验证后恢复两箱供已拣下限校验/)
assert.match(loading, /packageCount: Number\(pickedDynamicPlanItem\?\.minPackageCount\) - 1/)
assert.match(loading, /Number\(afterOverAuthority\?\.version\) === Number\(reassignedPlan\?\.version\)/)
assert.match(loading, /Number\(afterOverAuthorityItem\?\.remainingPackageCount\) === authoritativeRemaining/)
})
@@ -84,8 +84,6 @@ export async function run(context) {
const payload = {
outboundOrderId: null,
oceanBookingId: ocean.id,
containerBookingId: null,
loadingPlanId: null,
warehouseId: id(containerFlow?.warehouseId
|| context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
carrierId: id(carrier.id), carrierName: carrier.name,
@@ -101,7 +99,8 @@ export async function run(context) {
const appointmentId = id(appointment?.id)
context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response,
Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version))
&& !id(appointment?.containerBookingId) && !id(appointment?.loadingPlanId)
&& !Object.hasOwn(appointment || {}, 'containerBookingId')
&& !Object.hasOwn(appointment || {}, 'loadingPlanId')
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
if (!appointmentId) return
@@ -130,7 +129,8 @@ export async function run(context) {
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id)
&& !id(final?.containerBookingId) && !id(final?.loadingPlanId),
&& !Object.hasOwn(final || {}, 'containerBookingId')
&& !Object.hasOwn(final || {}, 'loadingPlanId'),
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
@@ -8,7 +8,7 @@ const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/s
test('supplier acceptance covers full CRUD-like lifecycle, carrier matrix and boundary failures',()=>{
for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','非BOOKING携带carrierCodes返回A0400并保留链路ID','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker))
assert.match(source,/carrierCodes:\['MATSON','COSCO'\]/)
assert.match(source,/JSON\.stringify\(detail\?\.carrierCodes\)===JSON\.stringify\(\['MATSON','COSCO'\]\)/)
assert.match(source,/sameCarrierCodes\(detail\?\.carrierCodes,\['MATSON','COSCO'\]\)/)
assert.doesNotMatch(source,/scope\s*:/)
})
test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s))
@@ -96,6 +96,9 @@ test('Admin consumes a server-paged container summary and never derives 15 from
assert.match(adminApi, /listContainerPlanProgress/)
assert.match(adminApi, /container-summaries/)
assert.match(adminView, /listContainerPlanProgress\(/)
assert.match(adminView, /containerRows\.value\s*=\s*result\.records/)
assert.match(adminView, /const pendingRecords\s*=\s*result\.records\.filter/)
assert.match(adminView, /containerRows\.value\s*=\s*pendingRecords/)
assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/)
assert.match(adminView, /if \(request !== containerOptionRequest\) return/)
assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/)
})
@@ -50,7 +50,9 @@ test('fixture creates and releases booking without creating or linking a contain
test('HTTP lifecycle covers uniqueness idempotency attachments and every action', () => {
for (const label of ['创建后订舱立即退出候选', '同订舱第二张非终态拖车单冲突',
'重复创建未改变版本和状态', '上传DISPATCH_ORDER拖车附件', '查询拖车附件列表',
'重复创建未改变版本和状态', '上传并关联DISPATCH_ORDER拖车附件', '查询拖车附件列表',
'删除首次上传的拖车附件引用', '重新引用平台归属拖车附件',
'删除重新引用的拖车附件', '恢复一份DISPATCH_ORDER拖车附件供后续状态流转',
'编辑订舱级拖车为USD应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放',
'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409',
'COMPLETED后订舱恢复候选', 'COMPLETED后同订舱可新建下一张拖车单',
@@ -1,5 +1,6 @@
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'truck-appointments'
const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true }
@@ -9,6 +10,9 @@ const FORBIDDEN_TRUCK_FIELDS = ['containerBookingId', 'loadingPlanId', 'containe
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function responseEvidence(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') }
function records(value) { return Array.isArray(value) ? value : value?.records || [] }
@@ -205,8 +209,10 @@ export async function run(context) {
const appointmentId = id(appointment?.id); if (!appointmentId) return
const path = `${TRUCKS}/${appointmentId}`
context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse,
!hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0,
`appointmentNo=${appointment?.appointmentNo || 'missing'}`, OPTIONS)
truckAppointmentFieldGaps(appointment).length === 0
&& !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB'
&& Number(appointment?.payableAmount) === 0,
`appointmentNo=${appointment?.appointmentNo || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(appointment).join('|') || 'none'}`, OPTIONS)
const afterCreateCandidate = await candidate(context, identity)
context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item,
`appointmentNo=${appointment.appointmentNo}`, OPTIONS)
@@ -223,12 +229,45 @@ export async function run(context) {
`dispatch-order-${identity.suffix}.pdf`)
const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`,
attachmentForm, context.token)
const attachment = context.apiSuccess(MODULE, '上传DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS)
const attachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS)
const attachmentListResponse = await request(context, 'GET', `${path}/attachments`)
const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS)
context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse,
records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'),
`attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS)
if (!attachment?.id || !attachment?.fileId) return
const deleteUploadedResponse = await request(context, 'DELETE',
`${path}/attachments/${encodeURIComponent(id(attachment?.id))}`)
context.addStep(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse,
deleteUploadedResponse.http === 200 && codeOf(deleteUploadedResponse) === '200',
responseEvidence(deleteUploadedResponse), OPTIONS)
const referencedResponse = await request(context, 'POST', `${path}/attachments`, {
fileId: id(attachment?.fileId), attachmentType: 'DISPATCH_ORDER',
fileName: attachment?.fileName || `dispatch-order-${identity.suffix}.pdf`,
fileHash: attachment?.fileHash || null,
})
const referenced = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referencedResponse, '', OPTIONS)
const referencedListResponse = await request(context, 'GET', `${path}/attachments`)
const referencedList = context.apiSuccess(MODULE, '回查重新引用的拖车附件', referencedListResponse, '', OPTIONS)
context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', referencedListResponse,
records(referencedList).some((item) => id(item?.id) === id(referenced?.id)
&& id(item?.fileId) === id(attachment?.fileId) && item?.attachmentType === 'DISPATCH_ORDER'),
`attachmentId=${id(referenced?.id) || 'missing'}, fileId=${id(attachment?.fileId) || 'missing'}`, OPTIONS)
const deleteReferencedResponse = await request(context, 'DELETE',
`${path}/attachments/${encodeURIComponent(id(referenced?.id))}`)
context.addStep(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse,
deleteReferencedResponse.http === 200 && codeOf(deleteReferencedResponse) === '200',
responseEvidence(deleteReferencedResponse), OPTIONS)
const replacementForm = new FormData(); replacementForm.append('attachmentType', 'DISPATCH_ORDER')
replacementForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER-REPLACEMENT`)],
{ type: 'application/pdf' }), `dispatch-order-replacement-${identity.suffix}.pdf`)
const replacementResponse = await context.requestMultipart('POST', `${path}/attachments/upload`,
replacementForm, context.token)
const replacement = context.apiSuccess(MODULE, '恢复一份DISPATCH_ORDER拖车附件供后续状态流转',
replacementResponse, '', OPTIONS)
context.assert(MODULE, '恢复附件使用新引用且仍指向平台文件', replacementResponse,
Boolean(id(replacement?.id) && id(replacement?.fileId)) && id(replacement?.id) !== id(attachment?.id),
`attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS)
const editedResponse = await request(context, 'PUT', path,
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 }))
@@ -258,9 +297,10 @@ export async function run(context) {
const finalDetailResponse = await request(context, 'GET', path)
const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS)
context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse,
finalDetail?.status === 'COMPLETED' && !hasForbiddenTruckFields(finalDetail)
finalDetail?.status === 'COMPLETED' && truckAppointmentFieldGaps(finalDetail).length === 0
&& !hasForbiddenTruckFields(finalDetail)
&& id(finalDetail?.oceanBookingId) === identity.oceanBookingId,
`appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}`, OPTIONS)
`appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS)
const afterCompleteCandidate = await candidate(context, identity)
context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item),
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
+10 -1
View File
@@ -247,6 +247,8 @@ Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPane
# 称重确认后的整票库位块和智能装柜暂估数量已成为跨端权威事实,索引必须直达并锁住回退顺序。
$measurementLocationOccupationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java')
$inboundMeasurementService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java')
$measurementSubmissionService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java')
$smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
$inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql')
$adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
@@ -254,10 +256,17 @@ $clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web
Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由'
Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口'
Assert-Contains $firstlegAgents '实测体积、测量批次冻结申报体积、Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序'
Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择'
Assert-Contains $firstlegAgents '再无有效值才查询 Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序'
Assert-Contains $firstlegAgents '库位指引失败只记录警告,不阻断称重确认' 'Firstleg 称重库位非阻断语义'
Assert-Contains $firstlegAgents '`ensureForSubmission` 再次补齐和校验' 'Firstleg 提交库位补偿入口'
Assert-Contains $firstlegAgents '槽位与称重唯一键必须包含 `inbound_id`' 'Firstleg 部分拣货快照身份'
Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态'
Assert-Contains $measurementLocationOccupationService 'orderCargo.measurementDeclaration' 'Firstleg Order 申报体积回退'
Assert-Contains $measurementLocationOccupationService 'measuredVolume.max(declaredVolume)' 'Firstleg 测量批次体积取较大值'
Assert-Contains $measurementLocationOccupationService 'ensureForSubmission' 'Firstleg 提交库位补齐实现'
Assert-Contains $inboundMeasurementService 'ensureLocationWithoutBlocking' 'Firstleg 称重库位非阻断实现'
Assert-Contains $measurementSubmissionService 'locations.ensureForSubmission' 'Firstleg 提交库位补齐调用'
Assert-Contains $firstlegOpenApi '测量批次冻结的申报体积、Order 最新客户申报体积' '称重确认体积回退 OpenAPI'
Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)' '部分拣货块槽快照唯一键'
Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)' '部分拣货称重快照唯一键'
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import {
PALLET_LOCATION_SCENARIOS,
inspectCrossScenarioIsolation,
inspectScenarioReport,
parseMatrixArguments,
} from './pallet-location-test-env-acceptance.mjs'
const source = await readFile(new URL('./pallet-location-test-env-acceptance.mjs', import.meta.url), 'utf8')
test('pallet location matrix declares deterministic one, two, and three-image scenarios', () => {
assert.equal(PALLET_LOCATION_SCENARIOS.length, 5)
assert.deepEqual(PALLET_LOCATION_SCENARIOS.map((item) => item.expectedPalletCount), [1, 1, 1, 2, 3])
assert.match(source, /--modules', 'orders,inbound-measurement'/)
assert.match(source, /--verify-pallet-location-guide', 'true'/)
assert.match(source, /--wait-split-package-count', '18'/)
assert.match(source, /--wait-split-package-count', '12'/)
assert.match(source, /--route-mode', 'random', '--wait-split-random-value', '2'/)
assert.match(source, /第3张确认后扩容只追加且既有槽身份不漂移/)
assert.match(source, /beforeSlots=1, afterSlots=2/)
assert.match(source, /beforeSlots=2, afterSlots=3/)
})
test('matrix options select known scenarios without duplicate or unknown names', () => {
const parsed = parseMatrixArguments([
'--origin-warehouse-name', '测试仓',
'--scenarios', 'single-image-single-slot,two-image-incremental-growth',
'--output=reports/custom.json',
])
assert.equal(parsed.originWarehouseName, '测试仓')
assert.deepEqual(parsed.scenarioNames, ['single-image-single-slot', 'two-image-incremental-growth'])
assert.equal(parsed.output, 'reports/custom.json')
assert.throws(() => parseMatrixArguments(['--scenarios', 'unknown']), /未知场景/)
assert.throws(() => parseMatrixArguments(['--scenarios', 'measured-volume-growth,measured-volume-growth']), /重复场景/)
})
test('scenario inspection requires deletion release, refill reuse, WAIT_SPLIT, and guide identity', () => {
const scenario = PALLET_LOCATION_SCENARIOS[0]
const requiredNames = [
'删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY',
'替代图片按新 resultId 复用原空槽',
'固定客户第 1 单停在待分柜',
'报告敏感信息扫描',
]
const report = {
success: true,
marker: 'TEST',
summary: { total: 10, failed: 0 },
createdResources: {
inboundMeasurement: { inboundId: '11', measurementId: '12', palletCount: 1, processStatus: 'WAIT_SPLIT' },
inboundMeasurementLocationGuide: {
inboundId: '11', blockId: '13', reservationId: '14', locationId: '15',
deleteSucceeded: true, slotReused: true,
},
},
steps: [
...requiredNames.map((name) => ({ name, passed: true })),
{ name: '第1张确认后整票库位块计数与槽身份完整', passed: true,
evidence: 'blockId=13, reserved=1, actual=1, empty=0, slots=1' },
],
}
const inspected = inspectScenarioReport(scenario, report, 0)
assert.equal(inspected.passed, true)
report.steps.find((step) => step.name.startsWith('删除只解绑')).passed = false
assert.equal(inspectScenarioReport(scenario, report, 0).passed, false)
})
test('scenario inspection detects naturally observed shared A0 from evidence without coupling to step wording', () => {
const scenario = PALLET_LOCATION_SCENARIOS[0]
const report = {
success: false,
createdResources: {},
steps: [{ name: '正式数据 A0 共享溢出观测', passed: true,
evidence: 'sharedOverflow=true, slots=1:reservation:A0' }],
}
assert.equal(inspectScenarioReport(scenario, report, 1).resources.sharedOverflowObserved, true)
})
test('three-image scenario rejects a no-op growth assertion even when the base step passed', () => {
const scenario = PALLET_LOCATION_SCENARIOS.at(-1)
const report = {
success: true,
createdResources: {
inboundMeasurement: { palletCount: 3, processStatus: 'WAIT_SPLIT' },
inboundMeasurementLocationGuide: {
blockId: '1', reservationId: '2', locationId: '3', deleteSucceeded: true, slotReused: true,
},
},
steps: [
{ name: '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', passed: true },
{ name: '替代图片按新 resultId 复用原空槽', passed: true },
{ name: '固定客户第 1 单停在待分柜', passed: true },
{ name: '报告敏感信息扫描', passed: true },
{ name: '第2张确认后扩容只追加且既有槽身份不漂移', passed: true,
evidence: 'beforeSlots=3, afterSlots=3' },
{ name: '第3张确认后扩容只追加且既有槽身份不漂移', passed: true,
evidence: 'beforeSlots=3, afterSlots=3' },
],
}
const inspected = inspectScenarioReport(scenario, report, 0)
assert.equal(inspected.assertions.requiredStepEvidence, false)
assert.deepEqual(inspected.mismatchedStepEvidence, scenario.requiredSteps)
})
test('cross-scenario inspection permits shared A0 location but rejects shared reservation or block', () => {
const results = [
{ resources: { inboundId: '1', blockId: '11', reservationId: '21', locationId: 'A0' } },
{ resources: { inboundId: '2', blockId: '12', reservationId: '22', locationId: 'A0' } },
]
assert.equal(inspectCrossScenarioIsolation(results).passed, true)
results[1].resources.reservationId = '21'
assert.equal(inspectCrossScenarioIsolation(results).passed, false)
})
test('matrix does not disable locations, execute SQL, deploy, or use a shell child process', () => {
assert.doesNotMatch(source, /location-status|batch-disable|UPDATE\s+|DELETE\s+FROM|INSERT\s+INTO/i)
assert.doesNotMatch(source, /jenkins|docker\s+compose|kubectl/i)
assert.match(source, /shell: false/)
assert.match(source, /A0 仅在普通库位自然不足时观测/)
})
@@ -0,0 +1,248 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
export const PALLET_LOCATION_SCENARIOS = Object.freeze([
{
name: 'single-image-single-slot',
description: '1 箱、1 张称重图,验证最小库位块与删除补传复用',
expectedPalletCount: 1,
args: ['--wait-split-package-count', '1', '--wait-split-random-value', '4'],
expectedGuideEvidence: /reserved=1, actual=1, empty=0, slots=1/,
},
{
name: 'single-image-two-reserved-slots',
description: '18 箱、1 张称重图,验证体积预留 2 个连续槽且空槽不阻塞',
expectedPalletCount: 1,
args: ['--wait-split-package-count', '18', '--wait-split-random-value', '74'],
expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/,
},
{
name: 'measured-volume-growth',
description: '12 箱、1 张称重图,验证实测体积跨档后扩为 2 个槽',
expectedPalletCount: 1,
args: ['--wait-split-package-count', '12', '--wait-split-random-value', '87'],
expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/,
},
{
name: 'two-image-incremental-growth',
description: '2 张称重图,验证第二张只追加槽且原槽身份不漂移',
expectedPalletCount: 2,
args: ['--route-mode', 'random', '--wait-split-random-value', '1'],
requiredSteps: ['第2张确认后扩容只追加且既有槽身份不漂移'],
},
{
name: 'three-image-incremental-growth',
description: '小体积订单上传 3 张称重图,强制验证库位块 1→2→3 连续扩容',
expectedPalletCount: 3,
args: ['--route-mode', 'random', '--wait-split-random-value', '2'],
requiredSteps: [
'第2张确认后扩容只追加且既有槽身份不漂移',
'第3张确认后扩容只追加且既有槽身份不漂移',
],
requiredStepEvidence: [
{ name: '第2张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=1, afterSlots=2/ },
{ name: '第3张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=2, afterSlots=3/ },
],
},
])
const COMMON_REQUIRED_STEPS = Object.freeze([
'删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY',
'替代图片按新 resultId 复用原空槽',
'固定客户第 1 单停在待分柜',
'报告敏感信息扫描',
])
export function parseMatrixArguments(argv) {
const result = {
help: false,
originWarehouseName: '义乌仓',
scenarioNames: PALLET_LOCATION_SCENARIOS.map((item) => item.name),
output: '',
}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--help') { result.help = true; continue }
if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`)
const separator = argument.indexOf('=')
const name = separator >= 0 ? argument.slice(2, separator) : argument.slice(2)
const inline = separator >= 0 ? argument.slice(separator + 1) : undefined
const value = inline === undefined ? argv[++index] : inline
if (!value || value.startsWith('--')) throw new Error(`参数 --${name} 缺少值`)
if (name === 'origin-warehouse-name') result.originWarehouseName = value.trim()
else if (name === 'scenarios') result.scenarioNames = value.split(',').map((item) => item.trim()).filter(Boolean)
else if (name === 'output') result.output = value
else throw new Error(`无法识别的参数: --${name}`)
}
if (!result.originWarehouseName) throw new Error('origin-warehouse-name不能为空')
if (result.scenarioNames.length === 0) throw new Error('scenarios至少包含一个场景')
const available = new Set(PALLET_LOCATION_SCENARIOS.map((item) => item.name))
const unknown = result.scenarioNames.filter((name) => !available.has(name))
if (unknown.length > 0) throw new Error(`未知场景: ${unknown.join(',')}`)
if (new Set(result.scenarioNames).size !== result.scenarioNames.length) {
throw new Error('scenarios不能包含重复场景')
}
return result
}
export function inspectScenarioReport(scenario, report, exitCode = 0) {
const steps = Array.isArray(report?.steps) ? report.steps : []
const stepByName = new Map(steps.map((step) => [step.name, step]))
const requiredSteps = [...COMMON_REQUIRED_STEPS, ...(scenario.requiredSteps || [])]
const missingOrFailedSteps = requiredSteps.filter((name) => stepByName.get(name)?.passed !== true)
const mismatchedStepEvidence = (scenario.requiredStepEvidence || [])
.filter(({ name, pattern }) => !pattern.test(String(stepByName.get(name)?.evidence || '')))
.map(({ name }) => name)
const measurement = report?.createdResources?.inboundMeasurement || {}
const guide = report?.createdResources?.inboundMeasurementLocationGuide || {}
const guideEvidence = steps
.filter((step) => /整票库位块计数与槽身份完整$/.test(String(step.name || '')))
.map((step) => String(step.evidence || ''))
const guideEvidenceMatched = !scenario.expectedGuideEvidence
|| guideEvidence.some((evidence) => scenario.expectedGuideEvidence.test(evidence))
const assertions = {
childExitCode: exitCode === 0,
childReportSuccess: report?.success === true,
requiredSteps: missingOrFailedSteps.length === 0,
requiredStepEvidence: mismatchedStepEvidence.length === 0,
expectedPalletCount: Number(measurement.palletCount) === scenario.expectedPalletCount,
waitSplitReached: measurement.processStatus === 'WAIT_SPLIT',
locationIdentityRecorded: Boolean(guide.blockId && guide.reservationId && guide.locationId),
deletedSlotReused: guide.deleteSucceeded === true && guide.slotReused === true,
expectedGuideShape: guideEvidenceMatched,
}
const passed = Object.values(assertions).every(Boolean)
const sharedOverflowObserved = steps.some((step) =>
/sharedOverflow=true/.test(String(step.evidence || '')))
return {
name: scenario.name,
description: scenario.description,
passed,
assertions,
missingOrFailedSteps,
mismatchedStepEvidence,
summary: report?.summary || null,
resources: {
marker: report?.marker || null,
inboundId: measurement.inboundId || guide.inboundId || null,
measurementId: measurement.measurementId || null,
blockId: guide.blockId || null,
reservationId: guide.reservationId || null,
locationId: guide.locationId || null,
palletCount: measurement.palletCount ?? null,
processStatus: measurement.processStatus || null,
sharedOverflowObserved,
},
}
}
export function inspectCrossScenarioIsolation(results) {
const fields = ['inboundId', 'blockId', 'reservationId']
const assertions = Object.fromEntries(fields.map((field) => {
const values = results.map((result) => result.resources?.[field]).filter(Boolean).map(String)
return [`unique${field[0].toUpperCase()}${field.slice(1)}`, values.length === results.length
&& new Set(values).size === values.length]
}))
return { passed: Object.values(assertions).every(Boolean), assertions }
}
function usage() {
console.log(`用法:
node scripts/pallet-location-test-env-acceptance.mjs [选项]
选项:
--origin-warehouse-name <名称> 固定国内收货仓,默认“义乌仓”
--scenarios <逗号列表> 只执行指定场景,默认执行全部
--output <报告路径> 汇总报告路径;明细写入同名 -details 目录
--help 显示帮助
可选场景:
${PALLET_LOCATION_SCENARIOS.map((item) => `${item.name}: ${item.description}`).join('\n ')}`)
}
async function runChild(scriptPath, args) {
return await new Promise((resolvePromise) => {
const child = spawn(process.execPath, [scriptPath, ...args], {
cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: false,
})
child.once('error', (error) => resolvePromise({ exitCode: 1, error: error.message }))
child.once('exit', (code, signal) => resolvePromise({
exitCode: Number.isInteger(code) ? code : 1,
error: signal ? `子进程被信号 ${signal} 终止` : null,
}))
})
}
async function main() {
const options = parseMatrixArguments(process.argv.slice(2))
if (options.help) { usage(); return }
const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14)
const outputPath = resolve(options.output || `reports/pallet-location-http-matrix-${timestamp}.json`)
const extension = extname(outputPath)
const detailsName = `${basename(outputPath, extension)}-details`
const detailsDir = resolve(dirname(outputPath), detailsName)
const runnerPath = resolve('scripts/admin-test-env-acceptance.mjs')
await mkdir(detailsDir, { recursive: true })
const selected = options.scenarioNames.map((name) =>
PALLET_LOCATION_SCENARIOS.find((scenario) => scenario.name === name))
const startedAt = new Date().toISOString()
const results = []
for (const scenario of selected) {
const scenarioReportPath = resolve(detailsDir, `${scenario.name}.json`)
console.log(`\n[PALLET-LOCATION] START ${scenario.name}: ${scenario.description}`)
const child = await runChild(runnerPath, [
'--modules', 'orders,inbound-measurement',
'--origin-warehouse-name', options.originWarehouseName,
'--verify-pallet-location-guide', 'true',
'--output', scenarioReportPath,
...scenario.args,
])
let report = null
let reportReadError = child.error
try { report = JSON.parse(await readFile(scenarioReportPath, 'utf8')) }
catch (error) { reportReadError ||= error instanceof Error ? error.message : String(error) }
const inspected = inspectScenarioReport(scenario, report, child.exitCode)
results.push({ ...inspected, reportPath: scenarioReportPath, reportReadError })
console.log(`[PALLET-LOCATION] ${inspected.passed ? 'PASS' : 'FAIL'} ${scenario.name}`)
}
const isolation = inspectCrossScenarioIsolation(results)
const success = results.every((result) => result.passed) && isolation.passed
const aggregate = {
reportName: '订单测量与连续库位真实 HTTP 扩展矩阵',
generatedAt: new Date().toISOString(), startedAt, success,
originWarehouseName: options.originWarehouseName,
summary: {
totalScenarios: results.length,
passedScenarios: results.filter((result) => result.passed).length,
failedScenarios: results.filter((result) => !result.passed).length,
sharedOverflowObserved: results.some((result) => result.resources.sharedOverflowObserved),
},
isolation,
results,
coverageNotes: [
'每个场景使用独立订单、入库单和库位块,并验证跨场景 reservation 不串票。',
'A0 仅在普通库位自然不足时观测;脚本不会批量停用普通库位制造容量不足。',
'Warehouse 调用异常时不回滚订单测量主流程由服务单元测试覆盖;真实 HTTP 矩阵不关闭共享服务做故障注入。',
'脚本不执行 SQL、不部署服务、不直接操作数据库。',
],
}
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, `${JSON.stringify(aggregate, null, 2)}\n`, 'utf8')
console.log(`\nREPORT=${outputPath}`)
console.log(`RESULT=${success ? 'PASS' : 'FAIL'} scenarios=${results.length} passed=${aggregate.summary.passedScenarios} failed=${aggregate.summary.failedScenarios}`)
process.exitCode = success ? 0 : 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
}