This commit is contained in:
2026-11-04 18:08:19 +08:00
parent 0993dd446e
commit 51291b4c27
16 changed files with 468 additions and 47 deletions
+2 -2
View File
@@ -72,7 +72,7 @@
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。业务订单与实物履约证据仍会新增。
固定客户模式下,`fixed-resource-readonly.mjs` 为 `customers`、`system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 执行既有客户、初始化状态、权限/角色/账号、仓库、通知模板/任务、价格簿与海外主档的只读核验;记录 `coverageMode=READ_ONLY` 和 `lifecycleSkipped=true`,写入生命周期继续列为 coverage gap,不以查询通过代替写入验收。离线边界由 `fixed-resource-readonly-contract.test.mjs` 验证。纯写入边界 `supplemental-boundaries` 仍整模块 skipped。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
@@ -264,7 +264,7 @@ export async function run(context) {
混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
`mixed-container-receiving.mjs` 已接入默认全量,也可通过总入口 `--modules mixed-container-receiving` 定向执行,依赖客户资质准备。总入口使用独立 marker、上下文和资源区,报告保存在 `createdResources.isolatedModules["mixed-container-receiving"].dropshipPalletMatrix`,不覆盖主链订单/财务证据;内部直接调用的资源结构不变。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。内部助手使用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
@@ -0,0 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
import { FIXED_RESOURCE_READONLY_MODULES } from './fixed-resource-readonly.mjs'
function runner(mutate = value => value) {
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
const calls = []
context.request = async (method, path) => {
calls.push({ method, path })
assert.equal(method, 'GET', '固定资源只读检查不得写入或创建资源')
let value = path.includes('?page=') ? { page: 1, size: 20, total: 0, records: [] } : []
if (path.endsWith('/tenants/123')) value = { id: '123', status: 'ACTIVE' }
if (path.endsWith('/dimension-initialization')) value = { status: 'READY' }
if (path.endsWith('/permissions')) value = [{ id: '1', permissionCode: 'platform:customer:list' }]
if (path.endsWith('/roles')) value = [{ id: '2', roleCode: 'EXISTING_ROLE' }]
if (path.includes('/iam/users?')) value.records = [{ membershipId: '3', tenantId: '0' }], value.total = 1
if (path.includes('/warehouse-admin?')) value.records = [{ id: '4', tenantId: '0' }], value.total = 1
return { method, path, http: 200, body: { code: '200', data: mutate(value, path) } }
}
return { context, calls }
}
for (const module of FIXED_RESOURCE_READONLY_MODULES) test(`${module}复用现有数据,只读通过且保留生命周期缺口`, async () => {
const { context, calls } = runner()
await context.executeModule(module, () => assert.fail('不得调用原生命周期处理器'))
const result = context.modules[0]
assert.equal(result.success, true)
assert.ok(result.passed > 0)
assert.equal(result.skipped, undefined)
assert.equal(result.coverageMode, 'READ_ONLY')
assert.equal(result.lifecycleSkipped, true)
assert.ok(calls.length > 0)
assert.ok(calls.every(row => row.method === 'GET'))
assert.equal(context.coverageGaps.length, 1)
assert.equal(context.coverageGaps[0].blocking, false)
assert.equal(context.createdResources.fixedResourceReadOnly[module].lifecycle, 'NOT_TESTED')
})
for (const [label, module, mutate] of [
['客户不匹配', 'customers', (value, path) => path.endsWith('/123') ? { ...value, id: '999' } : value],
['客户被停用', 'customers', (value, path) => path.endsWith('/123') ? { ...value, status: 'DISABLED' } : value],
['初始化未就绪', 'customers', (value, path) => path.endsWith('/dimension-initialization') ? { status: 'PENDING' } : value],
['仓库非公共归属', 'master-data', value => ({ ...value, records: [{ id: '4', tenantId: '999' }], total: 1 })],
['平台账号非平台归属', 'system', (value, path) => path.includes('/users?')
? { ...value, records: [{ membershipId: '3', tenantId: '123' }] } : value],
['分页结构缺失', 'notifications', () => []],
['分页范围错误', 'notifications', value => ({ ...value, page: 2 })],
['价格目录结构错误', 'pricing', () => ({ records: [] })],
]) test(`${label}必须失败,禁止补建或执行原写入处理器`, async () => {
const { context, calls } = runner(mutate)
await context.executeModule(module, () => assert.fail('不能回退创建数据'))
assert.equal(context.modules[0].success, false)
assert.ok(context.modules[0].failed > 0)
assert.ok(calls.every(row => row.method === 'GET'))
})
test('权限错误保留为真实失败,不以空目录通过', async () => {
const { context } = runner()
context.request = async () => ({ http: 403, body: { code: 'A0403' } })
await context.executeModule('system', () => assert.fail('不能修改授权'))
assert.equal(context.modules[0].success, false)
})
@@ -0,0 +1,70 @@
import { normalizeFixedCustomerId } from './merchant-qualification-fixture.mjs'
export const FIXED_RESOURCE_READONLY_MODULES = Object.freeze([
'customers', 'system', 'master-data', 'notifications', 'pricing', 'phase4-overseas-master-data',
])
const IAM = '/api/platform/v1/iam'
const MASTER = '/api/platform/v1/last-leg/master-data'
const decimalId = value => /^[1-9]\d*$/.test(String(value ?? ''))
const pageShape = value => Array.isArray(value?.records)
&& Number(value.page) === 1 && Number(value.size) === 20
&& ['string', 'number'].includes(typeof value.total) && /^(0|[1-9]\d*)$/.test(String(value.total))
&& Number.isSafeInteger(Number(value.total))
&& Number(value.total) >= value.records.length && value.records.length <= 20
/** 固定资源只检查现有数据;生命周期缺口保留,不能以查询通过代替写入验收。 */
export async function runFixedResourceReadOnly(context, module) {
if (!context.options.fixedCustomerId || !FIXED_RESOURCE_READONLY_MODULES.includes(module))
throw Error('固定资源只读检查必须指定已有客户及受支持模块')
context.addCoverageGap(module, 'fixed-resource-lifecycle', '现有资源只读检查不覆盖创建、修改、发布及停用生命周期',
'复用已有客户、账号、角色和主档;生命周期仍为未验收', 'not-implemented', { blocking: false })
const proof = { mode: 'READ_ONLY', lifecycle: 'NOT_TESTED', checks: [] }
context.createdResources.fixedResourceReadOnly ??= {}
context.createdResources.fixedResourceReadOnly[module] = proof
async function get(label, path, validate) {
const response = await context.request('GET', path)
const value = context.apiSuccess(module, label, response)
const passed = response?.http === 200 && String(response.body?.code) === '200' && validate(value)
context.assert(module, `${label}结构及归属有效`, response, Boolean(passed), '', { deploymentBlocker: true })
proof.checks.push({ path, passed: Boolean(passed),
...(Array.isArray(value) ? { count: value.length }
: Array.isArray(value?.records) ? { count: value.records.length, total: Number(value.total) } : {}) })
if (!passed) throw Error(`${module}现有资源只读核验失败,禁止补建或修改`)
}
const page = (label, path, validate = () => true) => get(label, `${path}?page=1&size=20`,
value => pageShape(value) && value.records.every(validate))
switch (module) {
case 'customers': {
const customerId = normalizeFixedCustomerId(context.options.fixedCustomerId)
await get('读取指定固定客户档案', `${IAM}/tenants/${customerId}`,
value => String(value?.id) === customerId && value.status === 'ACTIVE')
await get('读取指定固定客户尺寸初始化状态', `${IAM}/tenants/${customerId}/dimension-initialization`,
value => value?.status === 'READY')
break
}
case 'system':
await get('读取既有权限目录', `${IAM}/permissions`, value => Array.isArray(value)
&& value.every(row => decimalId(row.id) && typeof row.permissionCode === 'string'))
await get('读取既有角色目录', `${IAM}/roles`, value => Array.isArray(value)
&& value.every(row => decimalId(row.id) && typeof row.roleCode === 'string'))
await page('读取既有平台账号分页', `${IAM}/users`, row => decimalId(row.membershipId) && String(row.tenantId) === '0')
break
case 'master-data':
await page('读取既有公共仓库分页', '/api/platform/v1/warehouse-admin',
row => decimalId(row.id) && String(row.tenantId) === '0')
break
case 'notifications':
await page('读取既有通知模板分页', '/api/platform/v1/notification-templates')
await page('读取既有通知任务分页', '/api/platform/v1/notification-tasks')
break
case 'pricing':
await get('读取既有价格簿目录', '/api/platform/v1/fba-pricing/carrier-rates/price-books', Array.isArray)
break
case 'phase4-overseas-master-data':
await page('读取既有海外报价分页', `${MASTER}/overseas-quotes`)
await page('读取既有汇率分页', `${MASTER}/exchange-rates`)
await page('读取既有海外车辆分页', `${MASTER}/vehicles`)
break
}
}
@@ -109,15 +109,14 @@ test('cross-customer financial helpers stop before provisioning or writing', asy
}
})
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
test('fixed resource runner excludes mutation-only boundary handlers without marking them passed', async () => {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
let calls = 0
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'notifications', 'pricing',
'phase4-overseas-master-data']) {
for (const name of ['supplemental-boundaries']) {
await runner.executeModule(name, async () => { calls++ })
}
assert.equal(calls, 0)
assert.equal(runner.modules.length, 7)
assert.equal(runner.modules.length, 1)
assert.ok(runner.modules.every(module => module.skipped && !module.success))
})
@@ -46,7 +46,7 @@ test('schedule interleaves three modes and uses FBA cumulative count rather than
})
function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false, repeatIncrement = false,
changedPayloadAccepted = false } = {}) {
changedPayloadAccepted = false, foreignPalletAccepted = false } = {}) {
const group = fixtures()
const expected = new Map(group.map(r => [r.source.inboundCode, 0]))
const schedule = mixedReceivingSchedule(group)
@@ -60,6 +60,9 @@ function driver({ earlyComplete = false, wrongModeAccepted = false, drift = fals
receivedPackages, pendingPackages: 4 - receivedPackages })) })
return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path, body) => {
if (path.endsWith('/pallets/resolve')) return { body: {
code: foreignPalletAccepted ? '200' : body.inboundCode === 'FBA' ? 'A0404' : 'A0409',
} }
if (body.idempotencyKey.endsWith('-duplicate')) {
return { body: path.endsWith('/carton') ? { code: 'A0409' }
: { code: '200', data: { result: 'ALREADY_RECEIVED', acceptedPackages: repeatIncrement ? 1 : 0 } } }
@@ -92,6 +95,10 @@ test('mixed receiving executes the whole schedule and completes only after all t
test('mixed receiving fails on early-completion acceptance', async () => {
await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true })))
})
test('mixed receiving fails if another inbound real reserved pallet is accepted', async () => {
await assert.rejects(receiveMixedContainer(driver({ foreignPalletAccepted: true })), /同柜另一票真实预留托盘必须拒绝/)
})
test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => {
await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true })))
})
@@ -105,7 +112,7 @@ test('mixed receiving rejects same-key different-payload success', async () => {
await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true })))
})
function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {}) {
function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false, postCompleteReceiptAccepted = false } = {}) {
const record = { order: { tenantId: 'CUSTOMER' }, source: { inboundCode: 'IN-OVERSEAS' },
expectedCartonMarks: [1, 2, 3, 4].map(index => `IN-OVERSEAS#4-${index}`),
overseas: { plan: { planNo: 'IP-SHORTAGE', pallets: [{ palletNo: 'PAL-1', warehousePalletId: '900' }] } } }
@@ -114,7 +121,12 @@ function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {})
const state = { received: 0, status: 'SIGNED', planStatus: 'PENDING_RECONCILIATION', confirmations: 0 }
const snapshot = () => ({ status: state.status, receivedPackages: state.received, pendingPackages: 4 - state.received })
return { record, marker: 'SHORTAGE-TEST', state, check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path) => ({ body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }),
request: async (_method, path, body) => {
if (/receive-after-(complete|shortage)$/.test(body.idempotencyKey || '')) {
return { body: { code: postCompleteReceiptAccepted ? '200' : 'A0404' } }
}
return { body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }
},
call: async (_name, method, path, body) => {
if (method === 'GET') return snapshot()
if (path.endsWith('/cartons/resolve')) return { inboundCode: record.source.inboundCode, destinationType: 'OVERSEAS' }
@@ -157,8 +169,12 @@ test('ordinary overseas shortage fails if stock includes an unreceived carton',
await assert.rejects(receiveOverseasShortage(shortageDriver({ stockPackages: 4 })))
})
function completeDriver(stockPackages = 4) {
const input = shortageDriver({ stockPackages })
test('ordinary overseas shortage fails if a missing carton can be received after confirmation', async () => {
await assert.rejects(receiveOverseasShortage(shortageDriver({ postCompleteReceiptAccepted: true })), /短少确认后不能继续收取未入库箱/)
})
function completeDriver(stockPackages = 4, postCompleteReceiptAccepted = false) {
const input = shortageDriver({ stockPackages, postCompleteReceiptAccepted })
const call = input.call
input.call = async (name, method, path, body) => {
if (path.endsWith('/complete')) {
@@ -184,3 +200,7 @@ test('ordinary overseas full receipt uses normal completion and stocks all four
test('ordinary overseas full receipt rejects stock inconsistent with actual received cartons', async () => {
await assert.rejects(receiveOverseasComplete(completeDriver(3)))
})
test('ordinary overseas full receipt fails if another receipt is accepted after completion', async () => {
await assert.rejects(receiveOverseasComplete(completeDriver(4, true)), /正常完成后新键扫码不能重新收货/)
})
@@ -33,6 +33,17 @@ export async function receiveMixedContainer({ group, call, check, request, marke
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const steps = mixedReceivingSchedule(group)
const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 }))
for (const record of group.filter(r => r.order.fulfillmentType === 'STANDARD')) {
const other = group.find(item => item !== record && item.order.fulfillmentType === 'STANDARD')
const before = await call('混柜串票托盘校验前回查', 'GET', base)
const invalid = await request('POST', `${base}/pallets/resolve`, {
inboundCode: record.source.inboundCode, palletCode: other.overseas.plan.pallets[0].palletNo,
})
check('同柜另一票真实预留托盘必须拒绝', invalid,
invalid.body?.code === (record.order.destinationType === 'FBA' ? 'A0404' : 'A0409'))
const after = await call('混柜串票托盘拒绝后回查', 'GET', base)
check('同柜串票托盘拒绝后全柜事实不变', null, JSON.stringify(before) === JSON.stringify(after))
}
for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) {
const before = await call('混柜错误收货模式前回查', 'GET', base)
const invalid = await request('POST', `${base}/receipts/bulk`, {
@@ -82,7 +93,7 @@ export async function receiveMixedContainer({ group, call, check, request, marke
return completed
}
export async function receiveOverseasComplete({ record, call, adminCall, check, marker }) {
export async function receiveOverseasComplete({ record, call, adminCall, check, request, marker }) {
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const inboundCode = record.source.inboundCode
const plan = record.overseas.plan
@@ -104,6 +115,11 @@ export async function receiveOverseasComplete({ record, call, adminCall, check,
&& Number(completed.receivedPackages) === expected && Number(completed.pendingPackages) === 0)
const replay = await call('正常完成原键重放', 'POST', `${base}/complete`, body)
check('正常完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
const afterComplete = await request('POST', `${base}/receipts/carton`, {
cartonMark: record.expectedCartonMarks[0], palletCode: pallet.palletNo,
idempotencyKey: `${marker}-receive-after-complete`,
})
check('正常完成后新键扫码不能重新收货', afterComplete, afterComplete.body?.code === 'A0404')
const finalPlan = await adminCall('回查全收最终计划', 'GET',
`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}?customerId=${encodeURIComponent(record.order.tenantId)}`)
check('全收计划完成且应收实收均为四箱', null, finalPlan.status === 'COMPLETED'
@@ -164,6 +180,11 @@ export async function receiveOverseasShortage({ record, call, adminCall, check,
check('普通海外仓短少确认成功', null, confirmed.status === 'PARTIAL_INBOUND')
const confirmReplay = await adminCall('后台短少确认同键重放', 'POST', `${planPath}/confirm-shortage`, confirmBody, headers)
check('后台短少确认重放不重复入库', null, JSON.stringify(confirmReplay) === JSON.stringify(confirmed))
const afterComplete = await request('POST', `${base}/receipts/carton`, {
cartonMark: record.expectedCartonMarks[actual], palletCode: pallet.palletNo,
idempotencyKey: `${marker}-receive-after-shortage`,
})
check('短少确认后不能继续收取未入库箱', afterComplete, afterComplete.body?.code === 'A0404')
const finalPlan = await adminCall('回查短少入库最终计划', 'GET', `${planPath}?customerId=${encodeURIComponent(customerId)}`)
check('短少入库保留原应收及实际箱数', null, finalPlan.status === 'PARTIAL_INBOUND'
&& Number(finalPlan.expectedPackageCount) === expected && Number(finalPlan.pickedPackageCount) === actual)