优化
This commit is contained in:
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url'
|
|||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
|
|
||||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||||
const OPTIONAL_MODULES = ['phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||||
@@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MODULE_DEPENDENCIES = new Map([
|
const MODULE_DEPENDENCIES = new Map([
|
||||||
|
['phase5-root-fulfillment', ['merchant-qualification-fixture']],
|
||||||
['pda-loading-link-regression', ['merchant-qualification-fixture']],
|
['pda-loading-link-regression', ['merchant-qualification-fixture']],
|
||||||
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
|
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
|
||||||
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
|
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
|
||||||
@@ -343,6 +344,7 @@ export function parseArguments(argv) {
|
|||||||
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
|
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
|
||||||
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
|
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
|
||||||
const requiresMarkerOwnedMerchantFixture = [
|
const requiresMarkerOwnedMerchantFixture = [
|
||||||
|
'phase5-root-fulfillment',
|
||||||
'phase4-overseas-business',
|
'phase4-overseas-business',
|
||||||
'phase4-overseas-return-receipt',
|
'phase4-overseas-return-receipt',
|
||||||
'phase4-overseas-client-services',
|
'phase4-overseas-client-services',
|
||||||
@@ -399,7 +401,7 @@ export function validateExecutionOptions(options) {
|
|||||||
}
|
}
|
||||||
if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported')
|
if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported')
|
||||||
if ('overseasGateway' in options) throw new Error('Use the single gateway option')
|
if ('overseasGateway' in options) throw new Error('Use the single gateway option')
|
||||||
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module))
|
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading', 'phase5-root-fulfillment'].includes(module))
|
||||||
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
|
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
|
||||||
throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)')
|
throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
||||||
|
|
||||||
|
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数;缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
|
||||||
|
|
||||||
单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。
|
单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。
|
||||||
|
|
||||||
普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。
|
普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { completeDropshipPalletization } from './inbound-measurement.mjs'
|
import { completeDropshipPalletization, verifyMeasurementSubmitReplay,
|
||||||
|
verifyMeasurementApprovalReplay } from './inbound-measurement.mjs'
|
||||||
|
|
||||||
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
|
||||||
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
||||||
@@ -191,3 +192,131 @@ test('pregenerated pallet regression detects duplicate replay and orphan empty p
|
|||||||
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
|
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
|
||||||
assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed))
|
assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function measurementReplayContext(approved = false, mutate = () => {}, submitCode = 'A0409') {
|
||||||
|
const input = { inboundId: '9007199254740993101', tenantId: '9007199254740993102',
|
||||||
|
measurementId: '9007199254740993103' }
|
||||||
|
const state = {
|
||||||
|
measurement: { inboundId: input.inboundId, measurementId: input.measurementId, inboundCode: 'REPLAY-IN',
|
||||||
|
measurementStatus: approved ? 'APPROVED' : 'SUBMITTED', measureReviewStatus: approved ? 'APPROVED' : 'PENDING',
|
||||||
|
processStatus: approved ? 'WAIT_SPLIT' : 'MEASURE_PENDING', version: 3, inboundVersion: 4,
|
||||||
|
actualPackageCount: 4, actualGrossWeightKg: 100, actualVolumeCbm: 1,
|
||||||
|
items: [{ id: '9007199254740993104', packageCount: 4, boxCode: 'REPLAY-IN#4-1' }],
|
||||||
|
weightResults: [{ id: '9007199254740993105', fileId: '9007199254740993115', grossWeightKg: 100 }],
|
||||||
|
palletLocationGuide: { blockId: '9007199254740993106', slots: [{ reservationId: '9007199254740993107' }] } },
|
||||||
|
inbound: { summary: { id: input.inboundId, tenantId: input.tenantId, version: 4 },
|
||||||
|
statusHistory: [{ id: '9007199254740993108', action: approved ? 'MEASURE_APPROVE' : 'MEASURE_SUBMIT' }] },
|
||||||
|
reviews: [{ id: '9007199254740993109', measurementId: input.measurementId, reviewResult: 'APPROVED' }],
|
||||||
|
fee: { feeId: '9007199254740993110', feeVersion: 1, inboundId: input.inboundId,
|
||||||
|
measurementId: input.measurementId, feeStatus: 'CALCULATED', totalAmount: 77 },
|
||||||
|
details: [{ id: '9007199254740993111', sourceFeeId: '9007199254740993110', sourceFeeVersion: 1,
|
||||||
|
inboundId: input.inboundId, amount: 77 }],
|
||||||
|
}
|
||||||
|
const calls = []
|
||||||
|
const context = { marker: 'MEASUREMENT-REPLAY', token: 'memory-only-test-token',
|
||||||
|
request: async (method, requestPath, body, token, headers) => {
|
||||||
|
calls.push({ method, requestPath, body: structuredClone(body), token, headers })
|
||||||
|
if (method === 'POST') {
|
||||||
|
mutate(state)
|
||||||
|
if (requestPath.includes('/measurement/submit?')) return {
|
||||||
|
http: 200, body: { code: submitCode }, requestId: 'submit-repeat', traceId: 'trace-submit',
|
||||||
|
}
|
||||||
|
assert.match(requestPath, /\/measurements\/approve\?/)
|
||||||
|
return { http: 200, body: { code: '200', data: {
|
||||||
|
initialPricingSucceeded: true, initialFee: structuredClone(state.fee),
|
||||||
|
} }, requestId: 'approve-repeat', traceId: 'trace-approve' }
|
||||||
|
}
|
||||||
|
let data
|
||||||
|
if (requestPath.includes('/measurement?')) data = state.measurement
|
||||||
|
else if (requestPath.startsWith(`/api/platform/inbounds/${input.inboundId}?`)) data = state.inbound
|
||||||
|
else if (requestPath.includes('/measurement-review-history?')) data = {
|
||||||
|
records: state.reviews, total: state.reviews.length, page: 1, size: 200,
|
||||||
|
}
|
||||||
|
else if (requestPath.includes('/fees/latest?')) data = state.fee
|
||||||
|
else if (requestPath.includes('/fee-details?')) {
|
||||||
|
assert.equal(new URL(requestPath, 'https://example.invalid').searchParams.get('includeHistory'), 'true')
|
||||||
|
data = state.details
|
||||||
|
} else throw new Error(`unexpected ${method} ${requestPath}`)
|
||||||
|
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'read', traceId: 'trace' }
|
||||||
|
},
|
||||||
|
apiSuccess: (_module, name, response) => {
|
||||||
|
assert.equal(response.http, 200, name); assert.equal(response.body.code, '200', name); return response.body.data
|
||||||
|
},
|
||||||
|
assert: (_module, name, _response, passed) => assert.ok(passed, name),
|
||||||
|
}
|
||||||
|
return { input, state, context, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('submitted measurement repeats the original payload, requires conflict and preserves all readable facts', async () => {
|
||||||
|
const fixture = measurementReplayContext()
|
||||||
|
const payload = { measurementId: fixture.input.measurementId, version: 2 }
|
||||||
|
const evidence = await verifyMeasurementSubmitReplay(fixture.context, fixture.input, payload)
|
||||||
|
assert.equal(evidence.rejected, true); assert.equal(evidence.unchanged, true)
|
||||||
|
assert.equal(evidence.beforeDigest, evidence.afterDigest)
|
||||||
|
assert.equal(evidence.historyCount, 1)
|
||||||
|
assert.deepEqual(fixture.calls.find(call => call.method === 'POST').body, payload)
|
||||||
|
assert.equal(fixture.calls.find(call => call.method === 'POST').headers, undefined)
|
||||||
|
assert.equal(Object.hasOwn(evidence, 'weightResults'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [name, mutate] of [
|
||||||
|
['new batch', state => { state.measurement.measurementId = '9007199254740993199' }],
|
||||||
|
['batch version', state => { state.measurement.version += 1 }],
|
||||||
|
['extra item', state => state.measurement.items.push({ id: '9007199254740993198', packageCount: 1 })],
|
||||||
|
['changed weight', state => { state.measurement.weightResults[0].grossWeightKg += 1 }],
|
||||||
|
['extra inbound history', state => state.inbound.statusHistory.push({ id: '9007199254740993197' })],
|
||||||
|
]) {
|
||||||
|
test(`duplicate measurement submission detects ${name}`, async () => {
|
||||||
|
const fixture = measurementReplayContext(false, mutate)
|
||||||
|
await assert.rejects(verifyMeasurementSubmitReplay(fixture.context, fixture.input,
|
||||||
|
{ measurementId: fixture.input.measurementId, version: 2 }), /零漂移|原入库原批次/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('duplicate measurement submission must not accept a successful mutation response', async () => {
|
||||||
|
const fixture = measurementReplayContext(false, () => {}, '200')
|
||||||
|
await assert.rejects(verifyMeasurementSubmitReplay(fixture.context, fixture.input,
|
||||||
|
{ measurementId: fixture.input.measurementId, version: 2 }), /再次提交明确A0409/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('approval replay uses the original key and old expectedVersion and compares all fee versions and histories', async () => {
|
||||||
|
const fixture = measurementReplayContext(true)
|
||||||
|
const payload = { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }
|
||||||
|
const evidence = await verifyMeasurementApprovalReplay(fixture.context, fixture.input, payload, 'original-review-key')
|
||||||
|
const post = fixture.calls.find(call => call.method === 'POST')
|
||||||
|
assert.deepEqual(post.body, payload)
|
||||||
|
assert.equal(post.headers['Idempotency-Key'], 'original-review-key')
|
||||||
|
assert.equal(evidence.unchanged, true)
|
||||||
|
assert.equal(evidence.beforeDigest, evidence.afterDigest)
|
||||||
|
assert.equal(evidence.feeId, fixture.state.fee.feeId)
|
||||||
|
assert.equal(evidence.feeVersion, 1)
|
||||||
|
assert.equal(evidence.reviewIds.length, 1)
|
||||||
|
assert.equal(evidence.feeDetailIds.length, 1)
|
||||||
|
assert.equal(JSON.stringify(evidence).includes(fixture.context.token), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [name, mutate] of [
|
||||||
|
['different fee ID', state => { state.fee.feeId = '9007199254740993191' }],
|
||||||
|
['extra fee version', state => { state.fee.feeVersion += 1 }],
|
||||||
|
['extra fee detail', state => state.details.push({ ...state.details[0], id: '9007199254740993192' })],
|
||||||
|
['changed historical fee detail', state => { state.details[0].amount += 1 }],
|
||||||
|
['duplicate review record', state => state.reviews.push({ ...state.reviews[0], id: '9007199254740993193' })],
|
||||||
|
['extra inbound history', state => state.inbound.statusHistory.push({ id: '9007199254740993194' })],
|
||||||
|
]) {
|
||||||
|
test(`approval replay detects ${name} despite successful response`, async () => {
|
||||||
|
const fixture = measurementReplayContext(true, mutate)
|
||||||
|
await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input,
|
||||||
|
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'),
|
||||||
|
/零新增|唯一审核及已生成初算费用事实有效/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('review history uses the real reviewResult field and rejects a fabricated result alias', async () => {
|
||||||
|
const fixture = measurementReplayContext(true)
|
||||||
|
fixture.state.reviews[0] = { id: '9007199254740993109', measurementId: fixture.input.measurementId, result: 'APPROVED' }
|
||||||
|
await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input,
|
||||||
|
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'),
|
||||||
|
/唯一审核及已生成初算费用事实有效/)
|
||||||
|
assert.equal(fixture.calls.some(call => call.method === 'POST'), false)
|
||||||
|
assert.match(source, /item\.reviewResult === 'APPROVED'/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
const MODULE = 'inbound-measurement'
|
const MODULE = 'inbound-measurement'
|
||||||
const SCENARIO = 'mojia-carton-measurement'
|
const SCENARIO = 'mojia-carton-measurement'
|
||||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||||
@@ -28,6 +30,110 @@ function validPdf(response) {
|
|||||||
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
|
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function measurementReplayDigest(facts) {
|
||||||
|
return createHash('sha256').update(JSON.stringify(facts)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMeasurementReplayFacts(context, { inboundId, tenantId, measurementId }, name) {
|
||||||
|
const response = await context.request('GET',
|
||||||
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`)
|
||||||
|
const measurement = context.apiSuccess(MODULE, `${name}测量批次明细及重量事实`, response, '', OPTIONS)
|
||||||
|
const inboundResponse = await context.request('GET', `/api/platform/inbounds/${inboundId}?${query(tenantId)}`)
|
||||||
|
const inbound = context.apiSuccess(MODULE, `${name}入库状态及历史`, inboundResponse, '', OPTIONS)
|
||||||
|
const valid = asId(measurement?.inboundId) === inboundId && asId(measurement?.measurementId) === measurementId
|
||||||
|
&& Array.isArray(measurement?.items) && measurement.items.length > 0
|
||||||
|
&& Array.isArray(measurement?.weightResults) && measurement.weightResults.length > 0
|
||||||
|
&& new Set(measurement.items.map(item => asId(item.id))).size === measurement.items.length
|
||||||
|
&& new Set(measurement.weightResults.map(item => asId(item.id))).size === measurement.weightResults.length
|
||||||
|
&& asId(inbound?.summary?.id) === inboundId && asId(inbound?.summary?.tenantId) === tenantId
|
||||||
|
&& Array.isArray(inbound?.statusHistory)
|
||||||
|
context.assert(MODULE, `${name}原入库原批次明细及历史可核对`, response, valid,
|
||||||
|
`measurementId=${asId(measurement?.measurementId)}, items=${measurement?.items?.length ?? 'missing'}`
|
||||||
|
+ `, weights=${measurement?.weightResults?.length ?? 'missing'}`, OPTIONS)
|
||||||
|
if (!valid) throw new Error('测量重放核验缺少原批次或完整明细历史事实')
|
||||||
|
return { response, facts: { measurement, inbound } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyMeasurementSubmitReplay(context, input, payload) {
|
||||||
|
const before = await readMeasurementReplayFacts(context, input, '重复提交前')
|
||||||
|
const response = await context.request('POST',
|
||||||
|
`/api/platform/v1/first-leg/inbounds/${input.inboundId}/measurement/submit?${query(input.tenantId)}`, payload)
|
||||||
|
const rejected = response?.http === 200 && String(response.body?.code) === 'A0409'
|
||||||
|
context.assert(MODULE, '已提交测量再次提交明确A0409', response, rejected,
|
||||||
|
`HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, OPTIONS)
|
||||||
|
const after = await readMeasurementReplayFacts(context, input, '重复提交后')
|
||||||
|
const beforeDigest = measurementReplayDigest(before.facts)
|
||||||
|
const afterDigest = measurementReplayDigest(after.facts)
|
||||||
|
const unchanged = beforeDigest === afterDigest
|
||||||
|
context.assert(MODULE, '重复测量提交拒绝后批次版本明细重量及入库历史零漂移', after.response,
|
||||||
|
unchanged, `items=${after.facts.measurement.items.length}, weights=${after.facts.measurement.weightResults.length}`
|
||||||
|
+ `, histories=${after.facts.inbound.statusHistory.length}`, OPTIONS)
|
||||||
|
return { measurementId: input.measurementId, rejected, unchanged, beforeDigest, afterDigest,
|
||||||
|
itemIds: after.facts.measurement.items.map(item => asId(item.id)),
|
||||||
|
weightIds: after.facts.measurement.weightResults.map(item => asId(item.id)),
|
||||||
|
measurementVersion: after.facts.measurement.version,
|
||||||
|
inboundVersion: after.facts.measurement.inboundVersion, historyCount: after.facts.inbound.statusHistory.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyMeasurementApprovalReplay(context, input, payload, idempotencyKey) {
|
||||||
|
async function read(name) {
|
||||||
|
const snapshot = await readMeasurementReplayFacts(context, input, name)
|
||||||
|
const reviews = []; let total = null; let page = 1
|
||||||
|
while (total == null || reviews.length < total) {
|
||||||
|
const response = await context.request('GET',
|
||||||
|
`/api/platform/v1/inbounds/${input.inboundId}/measurement-review-history?${query(input.tenantId)}&page=${page}&size=200`)
|
||||||
|
const data = context.apiSuccess(MODULE, `${name}审核历史第${page}页`, response, '', OPTIONS)
|
||||||
|
const valid = Array.isArray(data?.records) && Number.isInteger(Number(data?.total)) && Number(data.total) >= 0
|
||||||
|
&& (total == null || total === Number(data.total))
|
||||||
|
&& (data.records.length > 0 || Number(data.total) === 0)
|
||||||
|
context.assert(MODULE, `${name}审核历史完整分页`, response, valid,
|
||||||
|
`page=${page}, total=${data?.total ?? 'missing'}`, OPTIONS)
|
||||||
|
if (!valid) throw new Error('测量审核重放缺少完整审核历史')
|
||||||
|
total = Number(data.total); reviews.push(...data.records); page += 1
|
||||||
|
}
|
||||||
|
const feeResponse = await context.request('GET',
|
||||||
|
`/api/platform/v1/inbounds/${input.inboundId}/fees/latest?${query(input.tenantId)}`)
|
||||||
|
const fee = context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS)
|
||||||
|
const detailsResponse = await context.request('GET',
|
||||||
|
`/api/platform/v1/inbounds/${input.inboundId}/fee-details?accountId=${encodeURIComponent(input.tenantId)}&includeHistory=true`)
|
||||||
|
const feeDetails = context.apiSuccess(MODULE, `${name}全部版本费用明细`, detailsResponse, '', OPTIONS)
|
||||||
|
const approvedReviewCount = reviews.filter(item => asId(item.measurementId) === input.measurementId
|
||||||
|
&& item.reviewResult === 'APPROVED').length
|
||||||
|
const valid = snapshot.facts.measurement.measurementStatus === 'APPROVED'
|
||||||
|
&& snapshot.facts.measurement.measureReviewStatus === 'APPROVED'
|
||||||
|
&& reviews.length === total && new Set(reviews.map(item => asId(item.id))).size === total
|
||||||
|
&& approvedReviewCount === 1
|
||||||
|
&& Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1
|
||||||
|
&& Array.isArray(feeDetails) && new Set(feeDetails.map(item => asId(item.id))).size === feeDetails.length
|
||||||
|
context.assert(MODULE, `${name}唯一审核及已生成初算费用事实有效`, detailsResponse, valid,
|
||||||
|
`reviews=${reviews.length}, feeId=${asId(fee?.feeId)}, feeVersion=${fee?.feeVersion ?? 'missing'}`
|
||||||
|
+ `, feeDetails=${feeDetails?.length ?? 'missing'}, approvedReviewCount=${approvedReviewCount}`
|
||||||
|
+ `, measurementStatus=${snapshot.facts.measurement.measurementStatus}`
|
||||||
|
+ `, measureReviewStatus=${snapshot.facts.measurement.measureReviewStatus}`, OPTIONS)
|
||||||
|
if (!valid) throw new Error('测量审核重放缺少唯一审核或已生成初算费用事实')
|
||||||
|
return { response: snapshot.response, facts: { ...snapshot.facts, reviews, fee, feeDetails } }
|
||||||
|
}
|
||||||
|
const before = await read('审核同键重放前')
|
||||||
|
const response = await context.request('POST',
|
||||||
|
`/api/platform/v1/inbounds/${input.inboundId}/measurements/approve?${query(input.tenantId)}`,
|
||||||
|
payload, context.token, { 'Idempotency-Key': idempotencyKey })
|
||||||
|
const replay = context.apiSuccess(MODULE, '测量审核同原键原请求重放成功', response, '', OPTIONS)
|
||||||
|
const after = await read('审核同键重放后')
|
||||||
|
const beforeDigest = measurementReplayDigest(before.facts)
|
||||||
|
const afterDigest = measurementReplayDigest(after.facts)
|
||||||
|
const unchanged = beforeDigest === afterDigest && replay?.initialPricingSucceeded === true
|
||||||
|
&& asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId)
|
||||||
|
&& Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion)
|
||||||
|
context.assert(MODULE, '测量审核重放保持批次费用ID版本全部明细审核及入库历史零新增', after.response,
|
||||||
|
unchanged, `feeId=${asId(after.facts.fee.feeId)}, version=${after.facts.fee.feeVersion}`
|
||||||
|
+ `, reviews=${after.facts.reviews.length}, details=${after.facts.feeDetails.length}`, OPTIONS)
|
||||||
|
return { measurementId: input.measurementId, unchanged, beforeDigest, afterDigest,
|
||||||
|
feeId: asId(after.facts.fee.feeId), feeVersion: after.facts.fee.feeVersion,
|
||||||
|
feeDetailIds: after.facts.feeDetails.map(item => asId(item.id)),
|
||||||
|
reviewIds: after.facts.reviews.map(item => asId(item.id)),
|
||||||
|
inboundHistoryCount: after.facts.inbound.statusHistory.length }
|
||||||
|
}
|
||||||
|
|
||||||
async function reusablePallets(context, warehouseId) {
|
async function reusablePallets(context, warehouseId) {
|
||||||
const cacheKey = asId(warehouseId)
|
const cacheKey = asId(warehouseId)
|
||||||
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
|
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
|
||||||
@@ -1055,14 +1161,17 @@ export async function run(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submissionPayload = { measurementId: draft.measurementId, version: Number(draft.version) }
|
||||||
|
const replayInput = { inboundId, tenantId, measurementId: asId(draft.measurementId) }
|
||||||
const submitResponse = await context.request('POST',
|
const submitResponse = await context.request('POST',
|
||||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`,
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`,
|
||||||
{ measurementId: draft.measurementId, version: Number(draft.version) })
|
submissionPayload)
|
||||||
const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse,
|
const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse,
|
||||||
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
|
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
|
||||||
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
|
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
|
||||||
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
|
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
|
||||||
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
|
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
|
||||||
|
const submissionReplay = await verifyMeasurementSubmitReplay(context, replayInput, submissionPayload)
|
||||||
|
|
||||||
const rejectedScanResponse = await multipartRequest(context, scanPath, {
|
const rejectedScanResponse = await multipartRequest(context, scanPath, {
|
||||||
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19),
|
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19),
|
||||||
@@ -1081,9 +1190,12 @@ export async function run(context) {
|
|||||||
`status=${pendingSummary?.processStatus ?? 'missing'}, version=${pendingSummary?.version ?? 'missing'}`, OPTIONS)
|
`status=${pendingSummary?.processStatus ?? 'missing'}, version=${pendingSummary?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const approvalPath = `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${query(tenantId)}`
|
const approvalPath = `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${query(tenantId)}`
|
||||||
const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, {
|
const approvalPayload = {
|
||||||
measurementId: draft.measurementId, expectedVersion: Number(pendingSummary?.version), reason: null,
|
measurementId: draft.measurementId, expectedVersion: Number(pendingSummary?.version), reason: null,
|
||||||
}, { 'Idempotency-Key': `${context.marker}-mojia-approve`.slice(0, 128) })
|
}
|
||||||
|
const approvalKey = `${context.marker}-mojia-approve`.slice(0, 128)
|
||||||
|
const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, approvalPayload,
|
||||||
|
{ 'Idempotency-Key': approvalKey })
|
||||||
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
|
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
|
||||||
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
|
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
|
||||||
|
|
||||||
@@ -1107,6 +1219,8 @@ export async function run(context) {
|
|||||||
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
|
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
|
||||||
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
|
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const approvalReplay = await verifyMeasurementApprovalReplay(context, replayInput, approvalPayload, approvalKey)
|
||||||
|
|
||||||
let sealedCartonMarks = []
|
let sealedCartonMarks = []
|
||||||
let sealedSourcePalletNos = []
|
let sealedSourcePalletNos = []
|
||||||
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
|
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
|
||||||
@@ -1156,6 +1270,7 @@ export async function run(context) {
|
|||||||
OPTIONS)
|
OPTIONS)
|
||||||
|
|
||||||
context.createdResources.inboundMeasurement = {
|
context.createdResources.inboundMeasurement = {
|
||||||
|
replayEvidence: { submission: submissionReplay, approval: approvalReplay },
|
||||||
sealedCartonMarks, sealedSourcePalletNos,
|
sealedCartonMarks, sealedSourcePalletNos,
|
||||||
marker: context.marker, tenantId, inboundId,
|
marker: context.marker, tenantId, inboundId,
|
||||||
orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId),
|
orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { approvedInboundReady } from './orders.mjs'
|
import { approvedInboundReady, verifyOrderApprovalReplay } from './orders.mjs'
|
||||||
|
|
||||||
const tenantId = '10001'
|
const tenantId = '10001'
|
||||||
const orderId = '20001'
|
const orderId = '20001'
|
||||||
@@ -48,3 +48,76 @@ test('orders acceptance polls approval convergence instead of only inbound id vi
|
|||||||
assert.doesNotMatch(source, /MEASURABLE_STATUSES\.has\(summary\?\.processStatus\)/)
|
assert.doesNotMatch(source, /MEASURABLE_STATUSES\.has\(summary\?\.processStatus\)/)
|
||||||
assert.doesNotMatch(source, /if \(success\(response\) && response\.body\?\.data\?\.summary\?\.inboundId\)/)
|
assert.doesNotMatch(source, /if \(success\(response\) && response\.body\?\.data\?\.summary\?\.inboundId\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function approvalReplayContext(mutate = () => {}, approvalCode = 'A0409') {
|
||||||
|
const inboundId = '40001'; const inboundCode = 'AT-APPROVAL'
|
||||||
|
const state = {
|
||||||
|
order: { order: { id: orderId, status: 'APPROVED', inboundCode, version: 2 },
|
||||||
|
cargoes: [], parties: [], internalNotes: [], statusHistory: [{ id: 'history-order', action: 'APPROVED' }] },
|
||||||
|
task: { id: '30001', tenantId, orderId, status: 'WAIT_MEASURE' },
|
||||||
|
inbound: { summary: { id: inboundId, tenantId, inboundCode, version: 2, processStatus: 'WAIT_MEASURE' },
|
||||||
|
statusHistory: [{ id: 'history-inbound', action: 'ORDER_APPROVED' }], files: [], palletLocations: [] },
|
||||||
|
rows: [{ id: inboundId, tenantId, orderId, inboundCode }],
|
||||||
|
}
|
||||||
|
const calls = []; const checks = []
|
||||||
|
const context = { marker: 'APPROVAL-REPLAY', createdResources: { orders: {} },
|
||||||
|
request: async (method, requestPath, body, token, headers) => {
|
||||||
|
calls.push({ method, requestPath, body, token, headers })
|
||||||
|
if (method === 'POST') {
|
||||||
|
assert.match(requestPath, /\/orders\/first-leg\/20001\/approve\?tenantId=10001$/)
|
||||||
|
assert.equal(body, undefined); assert.equal(headers, undefined)
|
||||||
|
mutate(state)
|
||||||
|
return { http: 200, body: { code: approvalCode }, requestId: 'repeat-approve', traceId: 'trace-approve' }
|
||||||
|
}
|
||||||
|
let data
|
||||||
|
if (requestPath.includes('/orders/first-leg/')) data = state.order
|
||||||
|
else if (requestPath.includes('/tasks/by-order/')) data = state.task
|
||||||
|
else if (requestPath.startsWith(`/api/platform/inbounds/${inboundId}?`)) data = state.inbound
|
||||||
|
else if (requestPath.startsWith('/api/platform/inbounds?')) data = {
|
||||||
|
records: state.rows, page: 1, size: 100, total: state.rows.length, pages: 1,
|
||||||
|
}
|
||||||
|
else throw new Error(`unexpected ${method} ${requestPath}`)
|
||||||
|
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'read', traceId: 'trace' }
|
||||||
|
},
|
||||||
|
apiSuccess: (_module, _name, response) => {
|
||||||
|
assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data
|
||||||
|
},
|
||||||
|
assert: (_module, name, _response, passed) => {
|
||||||
|
checks.push({ name, passed }); assert.ok(passed, name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { context, calls, checks, input: { tenantId, orderId, inboundId, inboundCode } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('repeated approval requires A0409 and compares order, task, inbound identity, histories and unique count', async () => {
|
||||||
|
const fixture = approvalReplayContext()
|
||||||
|
const result = await verifyOrderApprovalReplay(fixture.context, fixture.input)
|
||||||
|
assert.equal(result.rejected, true)
|
||||||
|
assert.equal(result.unchanged, true)
|
||||||
|
assert.equal(result.inboundCount, 1)
|
||||||
|
assert.equal(result.orderHistoryCount, 1)
|
||||||
|
assert.equal(result.inboundHistoryCount, 1)
|
||||||
|
assert.equal(result.beforeDigest, result.afterDigest)
|
||||||
|
assert.equal(fixture.calls.filter(call => call.method === 'POST').length, 1)
|
||||||
|
assert.equal(fixture.context.createdResources.orders.approvalReplay, result)
|
||||||
|
assert.equal(Object.hasOwn(result, 'order'), false)
|
||||||
|
assert.match(result.beforeDigest, /^[a-f0-9]{64}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [name, mutate] of [
|
||||||
|
['extra approval history', state => state.order.statusHistory.push({ id: 'repeat-history', action: 'APPROVED' })],
|
||||||
|
['changed inbound version', state => state.inbound.summary.version += 1],
|
||||||
|
['extra inbound history', state => state.inbound.statusHistory.push({ id: 'repeat-inbound-history' })],
|
||||||
|
['new fulfillment task', state => { state.task.id = '30002' }],
|
||||||
|
['duplicate source inbound', state => state.rows.push({ ...state.rows[0], id: '40002' })],
|
||||||
|
]) {
|
||||||
|
test(`approval replay detects ${name} instead of accepting unchanged response status`, async () => {
|
||||||
|
const fixture = approvalReplayContext(mutate)
|
||||||
|
await assert.rejects(verifyOrderApprovalReplay(fixture.context, fixture.input), /零漂移|唯一入库事实有效/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('successful repeated approval is not accepted as a substitute for the formal conflict contract', async () => {
|
||||||
|
const fixture = approvalReplayContext(() => {}, '200')
|
||||||
|
await assert.rejects(verifyOrderApprovalReplay(fixture.context, fixture.input), /再次审核明确A0409/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -86,6 +86,72 @@ export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderI
|
|||||||
&& id(task?.orderId) === id(orderId)
|
&& id(task?.orderId) === id(orderId)
|
||||||
&& id(task?.tenantId) === id(tenantId)
|
&& id(task?.tenantId) === id(tenantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function approvalReplayDigest(snapshot) {
|
||||||
|
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId, inboundCode }) {
|
||||||
|
const scope = `tenantId=${encodeURIComponent(tenantId)}`
|
||||||
|
const orderPath = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?${scope}`
|
||||||
|
const taskPath = `/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?${scope}`
|
||||||
|
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?${scope}`
|
||||||
|
async function read(name) {
|
||||||
|
const orderResponse = await context.request('GET', orderPath)
|
||||||
|
const detail = context.apiSuccess(MODULE, `${name}订单及状态历史`, orderResponse, '', CONTRACT_OPTIONS)
|
||||||
|
const taskResponse = await context.request('GET', taskPath)
|
||||||
|
const task = context.apiSuccess(MODULE, `${name}原履约任务`, taskResponse, '', CONTRACT_OPTIONS)
|
||||||
|
const inboundResponse = await context.request('GET', inboundPath)
|
||||||
|
const inbound = context.apiSuccess(MODULE, `${name}原入库单及历史`, inboundResponse, '', CONTRACT_OPTIONS)
|
||||||
|
const rows = []; let total = null; let page = 1
|
||||||
|
while (total == null || rows.length < total) {
|
||||||
|
const response = await context.request('GET', `/api/platform/inbounds?${scope}`
|
||||||
|
+ `&inboundCode=${encodeURIComponent(inboundCode)}&page=${page}&size=100`)
|
||||||
|
const data = context.apiSuccess(MODULE, `${name}入仓码来源入库列表第${page}页`, response, '', CONTRACT_OPTIONS)
|
||||||
|
const valid = pageOk(data) && Number.isInteger(Number(data.total)) && Number(data.total) >= 0
|
||||||
|
&& (total == null || total === Number(data.total))
|
||||||
|
&& (data.records.length > 0 || Number(data.total) === 0)
|
||||||
|
context.assert(MODULE, `${name}入库分页计数完整`, response, valid,
|
||||||
|
`page=${page}, total=${data?.total ?? 'missing'}`, CONTRACT_OPTIONS)
|
||||||
|
if (!valid) throw new Error('审核重放核验缺少完整入库分页事实')
|
||||||
|
total = Number(data.total); rows.push(...data.records); page += 1
|
||||||
|
}
|
||||||
|
const exact = rows.filter(row => id(row.inboundCode) === inboundCode)
|
||||||
|
const valid = detail?.order?.status === 'APPROVED' && id(detail.order.id) === orderId
|
||||||
|
&& id(detail.order.inboundCode) === inboundCode && Array.isArray(detail.statusHistory)
|
||||||
|
&& id(task?.orderId) === orderId && id(task?.tenantId) === tenantId && Boolean(id(task?.id))
|
||||||
|
&& id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId
|
||||||
|
&& id(inbound?.summary?.inboundCode) === inboundCode && Array.isArray(inbound?.statusHistory)
|
||||||
|
&& rows.length === total && new Set(rows.map(row => id(row.id))).size === total
|
||||||
|
&& exact.length === 1 && id(exact[0].id) === inboundId
|
||||||
|
context.assert(MODULE, `${name}审核态原身份与唯一入库事实有效`, inboundResponse, valid,
|
||||||
|
`exactInbounds=${exact.length}, taskId=${id(task?.id)}, inboundId=${id(inbound?.summary?.id)}`, CONTRACT_OPTIONS)
|
||||||
|
if (!valid) throw new Error('审核重放核验的订单任务或唯一入库身份不一致')
|
||||||
|
return { response: inboundResponse, facts: {
|
||||||
|
order: JSON.parse(orderPersistenceFacts(detail)), task, inbound, exactInbounds: exact,
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
const before = await read('审核重放前')
|
||||||
|
const response = await context.request('POST',
|
||||||
|
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/approve?${scope}`)
|
||||||
|
const rejected = response?.http === 200 && String(response.body?.code) === 'A0409'
|
||||||
|
context.assert(MODULE, '已审核订单再次审核明确A0409', response, rejected,
|
||||||
|
`HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
|
||||||
|
const after = await read('审核重放后')
|
||||||
|
const beforeDigest = approvalReplayDigest(before.facts)
|
||||||
|
const afterDigest = approvalReplayDigest(after.facts)
|
||||||
|
const unchanged = beforeDigest === afterDigest
|
||||||
|
context.assert(MODULE, '审核重复拒绝后订单入仓码历史任务及唯一入库事实零漂移', after.response,
|
||||||
|
unchanged, `inboundCount=1, orderHistories=${after.facts.order.statusHistory.length}`
|
||||||
|
+ `, inboundHistories=${after.facts.inbound.statusHistory.length}`, CONTRACT_OPTIONS)
|
||||||
|
const evidence = { marker: context.marker, tenantId, orderId, inboundId, inboundCode,
|
||||||
|
taskId: id(after.facts.task.id), inboundCount: after.facts.exactInbounds.length,
|
||||||
|
orderHistoryCount: after.facts.order.statusHistory.length,
|
||||||
|
inboundHistoryCount: after.facts.inbound.statusHistory.length,
|
||||||
|
beforeDigest, afterDigest, rejected, unchanged }
|
||||||
|
context.createdResources.orders.approvalReplay = evidence
|
||||||
|
return evidence
|
||||||
|
}
|
||||||
function tomorrow(days = 30) {
|
function tomorrow(days = 30) {
|
||||||
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
|
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
|
||||||
return date.toISOString().slice(0, 10)
|
return date.toISOString().slice(0, 10)
|
||||||
@@ -742,6 +808,9 @@ export async function run(context) {
|
|||||||
throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法修改申报并向 OCR/墨家模块交接')
|
throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法修改申报并向 OCR/墨家模块交接')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId,
|
||||||
|
inboundCode: id(inboundResult.detail?.summary?.inboundCode) })
|
||||||
|
|
||||||
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
|
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
|
||||||
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
|
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
|
||||||
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
|
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
|
||||||
|
|||||||
@@ -4,7 +4,36 @@ import path from 'node:path'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs'
|
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer,
|
||||||
|
crossContainerCartonProbe } from './phase4-overseas-pda.mjs'
|
||||||
|
|
||||||
|
test('same-root FBA cross-container rejection uses explicit real receiving prefix while preserving original physical box code',() => {
|
||||||
|
const current={containerNo:'ROOT-CABINET',inbounds:[{inboundCode:'ROOT'}]}
|
||||||
|
const child={containerNo:'CHILD-CABINET',containerBookingId:'20',sourceOrderId:'101',sourceInboundId:'201',
|
||||||
|
sourceStatus:'LOADED',inboundCode:'CHILD',destinationType:'FBA',fulfillmentType:'STANDARD',
|
||||||
|
cartonMarks:['ROOT#2-1'],receivingCartonMarks:['CHILD#PDA-CONTRACT-1']}
|
||||||
|
const before=JSON.stringify(child),probe=crossContainerCartonProbe([child],current)
|
||||||
|
assert.equal(probe.cartonMark,'CHILD#PDA-CONTRACT-1')
|
||||||
|
assert.equal(probe.mode,'FBA_RECEIVING_PREFIX');assert.equal(probe.sourceOrderId,'101')
|
||||||
|
assert.equal(JSON.stringify(child),before)
|
||||||
|
for(const fault of [
|
||||||
|
{...child,receivingCartonMarks:undefined}, {...child,receivingCartonMarks:['ROOT#PDA-CONTRACT-1']},
|
||||||
|
{...child,receivingCartonMarks:['CHILD#PDA-CONTRACT-']}, {...child,receivingCartonMarks:['CHILD#PDA-CONTRACT-0']},
|
||||||
|
{...child,containerNo:'ROOT-CABINET'}, {...child,sourceOrderId:null}, {...child,sourceInboundId:null},
|
||||||
|
{...child,containerBookingId:null}, {...child,sourceStatus:'WAIT_SPLIT'}, {...child,inboundCode:'ROOT'},
|
||||||
|
])assert.equal(crossContainerCartonProbe([fault],current),null)
|
||||||
|
assert.equal(crossContainerCartonProbe([],current),null)
|
||||||
|
assert.equal(crossContainerCartonProbe([child],{containerNo:'ROOT-CABINET',inbounds:[]}),null)
|
||||||
|
})
|
||||||
|
test('physical OVERSEAS and dropship probes still require another real loaded producer and keep the exact sealed code',() => {
|
||||||
|
const other={containerNo:'OTHER',containerBookingId:'20',sourceOrderId:'101',sourceInboundId:'201',
|
||||||
|
sourceStatus:'LOADED',inboundCode:'OTHER-SOURCE',destinationType:'OVERSEAS',fulfillmentType:'STANDARD',
|
||||||
|
cartonMarks:['ORIGINAL-PHYSICAL#2-1']}
|
||||||
|
const current={containerNo:'CURRENT',inbounds:[{inboundCode:'CURRENT-SOURCE'}]}
|
||||||
|
assert.equal(crossContainerCartonProbe([other],current).cartonMark,'ORIGINAL-PHYSICAL#2-1')
|
||||||
|
assert.equal(crossContainerCartonProbe([other],current).mode,'PHYSICAL_CARTON')
|
||||||
|
assert.equal(crossContainerCartonProbe([{...other,sourceInboundId:null}],current),null)
|
||||||
|
})
|
||||||
|
|
||||||
test('receiving selects one current booking and supplies mandatory booking identity', () => {
|
test('receiving selects one current booking and supplies mandatory booking identity', () => {
|
||||||
assert.match(source, /id\(item.sourceContainerBookingId\) === preferredBookingId/)
|
assert.match(source, /id\(item.sourceContainerBookingId\) === preferredBookingId/)
|
||||||
|
|||||||
@@ -14,6 +14,27 @@ export function uniqueContainerProducer(item, index, all) {
|
|||||||
return nonBlank(item?.containerNo)
|
return nonBlank(item?.containerNo)
|
||||||
&& all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index
|
&& all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index
|
||||||
}
|
}
|
||||||
|
export function crossContainerCartonProbe(producers, current) {
|
||||||
|
const currentCodes = new Set((current?.inbounds || []).map(item => id(item.inboundCode)))
|
||||||
|
if (!nonBlank(current?.containerNo) || !currentCodes.size) return null
|
||||||
|
for (const item of producers || []) {
|
||||||
|
if (!nonBlank(item?.containerNo) || item.containerNo === current?.containerNo
|
||||||
|
|| !longId(item?.containerBookingId) || !longId(item?.sourceOrderId)
|
||||||
|
|| !longId(item?.sourceInboundId) || item?.sourceStatus !== 'LOADED'
|
||||||
|
|| !nonBlank(item?.inboundCode) || currentCodes.has(item.inboundCode)) continue
|
||||||
|
const standardFba = item.destinationType === 'FBA' && item.fulfillmentType === 'STANDARD'
|
||||||
|
const marks = standardFba ? item.receivingCartonMarks
|
||||||
|
: (Array.isArray(item.cartonMarks) ? item.cartonMarks : [item.cartonMark])
|
||||||
|
const prefix = `${item.inboundCode}#PDA-CONTRACT-`
|
||||||
|
const cartonMark = (Array.isArray(marks) ? marks : []).map(id).find(value => nonBlank(value)
|
||||||
|
&& (!standardFba || (value.startsWith(prefix) && /^[1-9][0-9]*$/.test(value.slice(prefix.length)))))
|
||||||
|
if (cartonMark) return { cartonMark, containerNo:item.containerNo,
|
||||||
|
containerBookingId:id(item.containerBookingId), inboundCode:item.inboundCode,
|
||||||
|
sourceOrderId:id(item.sourceOrderId), sourceInboundId:id(item.sourceInboundId),
|
||||||
|
mode:standardFba ? 'FBA_RECEIVING_PREFIX' : 'PHYSICAL_CARTON' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||||
function code(response) { return String(response?.body?.code ?? '') }
|
function code(response) { return String(response?.body?.code ?? '') }
|
||||||
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
||||||
@@ -525,14 +546,13 @@ async function probeCartonResolveNegatives(context, token, current) {
|
|||||||
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
|
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
|
||||||
|
|
||||||
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
|
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
|
||||||
const crossContainerMark = allProducers
|
const crossContainerProbe = crossContainerCartonProbe(allProducers,current)
|
||||||
.filter((item) => id(item?.containerNo) !== id(current.containerNo))
|
if (crossContainerProbe) {
|
||||||
.flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark])
|
context.addStep(MODULE,'跨柜解析反证来源为另一真实实装柜及入仓码',null,true,
|
||||||
.map(id).find(nonBlank)
|
JSON.stringify(crossContainerProbe),OPTIONS)
|
||||||
if (crossContainerMark) {
|
|
||||||
await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝',
|
await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝',
|
||||||
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
|
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
|
||||||
{ cartonMark: crossContainerMark }, cartonResolveRejected)
|
{ cartonMark: crossContainerProbe.cartonMark }, cartonResolveRejected)
|
||||||
} else {
|
} else {
|
||||||
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
|
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion,
|
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
|
||||||
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
|
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
@@ -74,7 +74,9 @@ test('phase-five module proves receipt-save completion, cross-region convergence
|
|||||||
assert.match(source, /query\('\/api\/platform\/inbounds'/)
|
assert.match(source, /query\('\/api\/platform\/inbounds'/)
|
||||||
assert.match(source, /inboundCode: inbound\.inboundCode/)
|
assert.match(source, /inboundCode: inbound\.inboundCode/)
|
||||||
assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/)
|
assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/)
|
||||||
assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/)
|
assert.match(source, /verifyFulfillmentRootStatus\(context, \{ sourceOrderId, customerId, merchantToken \}\)/)
|
||||||
|
assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(rootOrderId\)\}/)
|
||||||
|
assert.match(source, /\/fulfillment-tree/)
|
||||||
assert.match(source, /mainStatus === 'COMPLETED'/)
|
assert.match(source, /mainStatus === 'COMPLETED'/)
|
||||||
assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/)
|
assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/)
|
||||||
assert.match(source, /manualExceptionNo/)
|
assert.match(source, /manualExceptionNo/)
|
||||||
@@ -550,3 +552,108 @@ test('G05 failures still independently review and preserve raw, group, each SHIP
|
|||||||
assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter)
|
assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function rootCompletionFixture({ childStatus = 'PROCESSING', rootStatus = 'PROCESSING', rootDelivered = true,
|
||||||
|
fullyLoaded = false, mainStatus = rootStatus === 'COMPLETED' ? 'COMPLETED' : 'DELIVERED', sourceOrderId = '1001' } = {}) {
|
||||||
|
const root = { order: { id: '1001', tenantId: '100', customerId: '100', orderRole: 'MASTER', rootOrderId: '1001',
|
||||||
|
orderNo: 'FL1001', status: rootStatus }, statusHistory: [
|
||||||
|
...(rootDelivered ? [{ action: 'OVERSEAS_DELIVERED' }] : []),
|
||||||
|
...(rootStatus === 'COMPLETED' ? [{ action: 'FULFILLMENT_COMPLETED' }] : [])] }
|
||||||
|
const child = { order: { id: '1002', tenantId: '100', customerId: '100', orderRole: 'CHILD',
|
||||||
|
rootOrderId: '1001', parentOrderId: '1001', orderNo: 'FL1001-1', status: childStatus } }
|
||||||
|
const tree = { parentOrderId: '1001', parentOrderNo: 'FL1001', originalPackageCount: 4,
|
||||||
|
cumulativeLoadedPackageCount: fullyLoaded ? 4 : 1, currentRemainderPackageCount: fullyLoaded ? 0 : 3,
|
||||||
|
children: [{ orderId: '1001', orderNo: 'FL1001', splitRole: 'LOADED', sourceOrderId: '1001', packageCount: 1 },
|
||||||
|
{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'REMAINDER', sourceOrderId: '1001', packageCount: 3 },
|
||||||
|
...(fullyLoaded ? [{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'LOADED', sourceOrderId: '1002', packageCount: 3 }] : [])] }
|
||||||
|
const context = contextFor((method, path) => {
|
||||||
|
if (path.includes('/fulfillment-tree')) return ok(tree)
|
||||||
|
if (path.includes('/first-leg/1002')) return ok(child)
|
||||||
|
if (path.includes('/first-leg/1001')) return ok(root)
|
||||||
|
return ok({ summary: { orderId: '1001', mainStatus } })
|
||||||
|
})
|
||||||
|
context.createdResources.phase5OverseasFulfillment = {}
|
||||||
|
return { root, child, tree, context,
|
||||||
|
args: { sourceOrderId, customerId: '100', merchantToken: 'ONLY-IN-MEMORY', pollAttempts: 1, pollIntervalMs: 0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real root1 signed plus effective remainder3 keeps raw PROCESSING and both platform/client DELIVERED', async () => {
|
||||||
|
const fixture = rootCompletionFixture()
|
||||||
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
||||||
|
assert.equal(result.passed, true)
|
||||||
|
assert.equal(result.expectedCompleted, false)
|
||||||
|
assert.deepEqual(result.pendingChildren.map(row => row.orderId), ['1002'])
|
||||||
|
assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), [])
|
||||||
|
const proof = fixture.context.createdResources.phase5OverseasFulfillment.orderCompletionProof
|
||||||
|
assert.equal(proof.tree.originalPackageCount, 4)
|
||||||
|
assert.equal(proof.tree.currentRemainderPackageCount, 3)
|
||||||
|
assert.deepEqual(proof.pendingChildOrderIds, ['1002'])
|
||||||
|
assert.doesNotMatch(JSON.stringify(proof), /ONLY-IN-MEMORY|password|accessToken|Bearer/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing, foreign, unknown or omitted real child cannot be treated as all fulfillment completed', async () => {
|
||||||
|
for (const mutate of [
|
||||||
|
fixture => { fixture.child.order = null },
|
||||||
|
fixture => { fixture.child.order.customerId = 'FOREIGN' },
|
||||||
|
fixture => { fixture.child.order.rootOrderId = 'OTHER' },
|
||||||
|
fixture => { fixture.child.order.parentOrderId = 'OTHER' },
|
||||||
|
fixture => { fixture.child.order.orderNo = 'WRONG' },
|
||||||
|
fixture => { fixture.child.order.status = undefined },
|
||||||
|
fixture => { fixture.child.order.status = 'UNKNOWN' },
|
||||||
|
fixture => { fixture.tree.children.pop() },
|
||||||
|
fixture => { fixture.tree.children = undefined },
|
||||||
|
]) {
|
||||||
|
const fixture = rootCompletionFixture(); mutate(fixture)
|
||||||
|
assert.equal(await verifyFulfillmentRootStatus(fixture.context, fixture.args), null)
|
||||||
|
assert.ok(fixture.context.assertions.some(row => !row.passed))
|
||||||
|
assert.equal(fixture.context.calls.some(call => call.path.startsWith('/api/merchant/')), false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('draft, rejected and pending child stay effective, and a premature root COMPLETED still fails', async () => {
|
||||||
|
for (const childStatus of ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'PROCESSING']) {
|
||||||
|
const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED' })
|
||||||
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
||||||
|
assert.equal(result.expectedCompleted, false)
|
||||||
|
assert.equal(result.passed, false)
|
||||||
|
assert.equal(result.pendingChildren.length, 1)
|
||||||
|
assert.ok(fixture.context.assertions.some(row => !row.passed))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('actual completed child or explicit CANCELLED permits completion only with root own receipt and raw completion fact', async () => {
|
||||||
|
for (const childStatus of ['COMPLETED', 'CANCELLED']) {
|
||||||
|
const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED', fullyLoaded: childStatus === 'COMPLETED' })
|
||||||
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
||||||
|
assert.equal(result.expectedCompleted, true)
|
||||||
|
assert.equal(result.passed, true)
|
||||||
|
assert.deepEqual(result.pendingChildren, [])
|
||||||
|
assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), [])
|
||||||
|
}
|
||||||
|
const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', fullyLoaded: true, rootDelivered: false })
|
||||||
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
||||||
|
assert.equal(result.expectedCompleted, false)
|
||||||
|
assert.equal(result.passed, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('receipt of a real child resolves and checks its owning root instead of completing the child as the aggregate', async () => {
|
||||||
|
const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, sourceOrderId: '1002' })
|
||||||
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
||||||
|
assert.equal(result.rootOrderId, '1001')
|
||||||
|
assert.equal(result.passed, true)
|
||||||
|
assert.ok(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1001'))
|
||||||
|
assert.equal(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1002'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all fragments completed still requires raw aggregate history and matching platform/customer completion', async () => {
|
||||||
|
const missingHistory = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true })
|
||||||
|
missingHistory.root.statusHistory = [{ action: 'OVERSEAS_DELIVERED' }]
|
||||||
|
assert.equal((await verifyFulfillmentRootStatus(missingHistory.context, missingHistory.args)).passed, false)
|
||||||
|
const notProjected = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, mainStatus: 'DELIVERED' })
|
||||||
|
assert.equal((await verifyFulfillmentRootStatus(notProjected.context, notProjected.args)).passed, false)
|
||||||
|
const foreignProjection = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true })
|
||||||
|
const originalRequest = foreignProjection.context.request
|
||||||
|
foreignProjection.context.request = async (...args) => args[1].startsWith('/api/merchant/')
|
||||||
|
? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args)
|
||||||
|
assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false)
|
||||||
|
})
|
||||||
|
|||||||
@@ -299,6 +299,102 @@ export function dispatchStockConservation(context, { customerId, warehouseId, di
|
|||||||
return { passed, proof, newLedger }
|
return { passed, proof, newLedger }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Completion follows the formal root and every real child; a signed shipment alone is not the aggregate.
|
||||||
|
export async function verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken,
|
||||||
|
pollAttempts = 45, pollIntervalMs = 1_000 }) {
|
||||||
|
const rawPath = orderId => query(`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { tenantId: customerId })
|
||||||
|
const source = await platform(context, 'GET', '读取签收来源原始订单履约归属', rawPath(sourceOrderId))
|
||||||
|
const sourceOrder = source.data?.order
|
||||||
|
const rootOrderId = sourceOrder?.orderRole === 'MASTER' ? id(sourceOrder.id) : id(sourceOrder?.rootOrderId)
|
||||||
|
const sourceOwned = success(source.response) && id(sourceOrder?.id) === sourceOrderId
|
||||||
|
&& id(sourceOrder?.tenantId) === customerId && id(sourceOrder?.customerId) === customerId
|
||||||
|
&& /^[1-9][0-9]*$/.test(rootOrderId)
|
||||||
|
&& (sourceOrder.orderRole === 'MASTER' || (sourceOrder.orderRole === 'CHILD' && id(sourceOrder.parentOrderId) === rootOrderId))
|
||||||
|
context.assert(MODULE, '签收来源订单属于本企业及唯一正式履约根', source.response, sourceOwned, `sourceOrderId=${sourceOrderId}, rootOrderId=${rootOrderId}`, OPTIONS)
|
||||||
|
if (!sourceOwned) return null
|
||||||
|
let root = rootOrderId === sourceOrderId ? source : await platform(context, 'GET', '读取原始履约根订单', rawPath(rootOrderId))
|
||||||
|
const treeResult = await platform(context, 'GET', '读取签收后的完整正式履约树',
|
||||||
|
query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}/fulfillment-tree`, { tenantId: customerId }))
|
||||||
|
const tree = treeResult.data
|
||||||
|
const rows = Array.isArray(tree?.children) ? tree.children : []
|
||||||
|
const treeOrderIds = new Set([rootOrderId, ...rows.map(row => id(row.orderId))])
|
||||||
|
const consumed = new Set(rows.filter(row => row.splitRole === 'LOADED').map(row => id(row.sourceOrderId)))
|
||||||
|
const loaded = rows.filter(row => row.splitRole === 'LOADED').reduce((sum, row) => sum + Number(row.packageCount), 0)
|
||||||
|
const remaining = rows.filter(row => row.splitRole === 'REMAINDER' && !consumed.has(id(row.orderId)))
|
||||||
|
.reduce((sum, row) => sum + Number(row.packageCount), 0)
|
||||||
|
const treeComplete = success(root.response) && success(treeResult.response)
|
||||||
|
&& id(root.data?.order?.id) === rootOrderId && root.data.order.orderRole === 'MASTER'
|
||||||
|
&& id(root.data.order.tenantId) === customerId && id(root.data.order.customerId) === customerId
|
||||||
|
&& Array.isArray(root.data.statusHistory)
|
||||||
|
&& id(tree?.parentOrderId) === rootOrderId && tree.parentOrderNo === root.data.order.orderNo
|
||||||
|
&& Array.isArray(tree.children)
|
||||||
|
&& ['originalPackageCount', 'cumulativeLoadedPackageCount', 'currentRemainderPackageCount'].every(field =>
|
||||||
|
tree[field] !== null && tree[field] !== undefined && Number.isSafeInteger(Number(tree[field])) && Number(tree[field]) >= 0)
|
||||||
|
&& loaded === Number(tree.cumulativeLoadedPackageCount) && remaining === Number(tree.currentRemainderPackageCount)
|
||||||
|
&& loaded + remaining === Number(tree.originalPackageCount)
|
||||||
|
&& rows.every(row => /^[1-9][0-9]*$/.test(id(row.orderId)) && /^[1-9][0-9]*$/.test(id(row.sourceOrderId))
|
||||||
|
&& treeOrderIds.has(id(row.sourceOrderId))
|
||||||
|
&& nonBlank(row.orderNo) && ['LOADED', 'REMAINDER'].includes(row.splitRole)
|
||||||
|
&& (row.splitRole === 'LOADED' ? id(row.orderId) === id(row.sourceOrderId) : id(row.orderId) !== id(row.sourceOrderId))
|
||||||
|
&& Number.isSafeInteger(Number(row.packageCount)) && Number(row.packageCount) > 0)
|
||||||
|
&& (sourceOrderId === rootOrderId || rows.some(row => id(row.orderId) === sourceOrderId))
|
||||||
|
context.assert(MODULE, '履约根及全部分片身份和树数量完整守恒', treeResult.response, treeComplete,
|
||||||
|
`rootOrderId=${rootOrderId}, loaded=${loaded}, remaining=${remaining}, original=${tree?.originalPackageCount ?? 'missing'}`, OPTIONS)
|
||||||
|
if (!treeComplete) return null
|
||||||
|
const children = []
|
||||||
|
const statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED']
|
||||||
|
for (const childId of [...new Set(rows.map(row => id(row.orderId)).filter(value => value !== rootOrderId))]) {
|
||||||
|
const current = await platform(context, 'GET', '逐项读取真实履约子单完成事实', rawPath(childId))
|
||||||
|
const child = current.data?.order
|
||||||
|
const owned = success(current.response) && id(child?.id) === childId && id(child?.tenantId) === customerId
|
||||||
|
&& id(child?.customerId) === customerId && child?.orderRole === 'CHILD'
|
||||||
|
&& id(child?.rootOrderId) === rootOrderId && id(child?.parentOrderId) === rootOrderId
|
||||||
|
&& rows.filter(row => id(row.orderId) === childId).every(row => row.orderNo === child.orderNo)
|
||||||
|
&& statuses.includes(child?.status)
|
||||||
|
context.assert(MODULE, '真实履约子单存在且客户根身份和状态有效', current.response, owned, `childOrderId=${childId}`, OPTIONS)
|
||||||
|
if (!owned) return null
|
||||||
|
children.push({ orderId: childId, orderNo: child.orderNo, status: child.status, rootOrderId })
|
||||||
|
}
|
||||||
|
const pendingChildren = children.filter(child => !['COMPLETED', 'CANCELLED'].includes(child.status))
|
||||||
|
const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true
|
||||||
|
const expectedCompleted = rootDelivered && pendingChildren.length === 0
|
||||||
|
const owned = context.createdResources.phase5OverseasFulfillment
|
||||||
|
if (owned) owned.orderCompletionProof = { sourceOrderId, rootOrderId,
|
||||||
|
tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount),
|
||||||
|
cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining,
|
||||||
|
children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId),
|
||||||
|
splitRole: row.splitRole, packageCount: Number(row.packageCount) })) },
|
||||||
|
children, rootDelivered, expectedCompleted, pendingChildOrderIds: pendingChildren.map(child => child.orderId) }
|
||||||
|
const domestic = await poll(() => context.request('GET',
|
||||||
|
query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}`, { tenantId: customerId })),
|
||||||
|
response => success(response) && id(response.body?.data?.summary?.orderId) === rootOrderId
|
||||||
|
&& (expectedCompleted ? response.body.data.summary.mainStatus === 'COMPLETED'
|
||||||
|
: rootDelivered ? response.body.data.summary.mainStatus === 'DELIVERED'
|
||||||
|
: nonBlank(response.body.data.summary.mainStatus) && response.body.data.summary.mainStatus !== 'COMPLETED'), pollAttempts, pollIntervalMs)
|
||||||
|
if (expectedCompleted) root = await platform(context, 'GET', '全部有效分片结束后回查原始履约根', rawPath(rootOrderId))
|
||||||
|
const rawPassed = success(root.response) && (expectedCompleted
|
||||||
|
? root.data?.order?.status === 'COMPLETED' && root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED')
|
||||||
|
: statuses.includes(root.data?.order?.status) && root.data.order.status !== 'COMPLETED'
|
||||||
|
&& !root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED'))
|
||||||
|
const platformPassed = success(domestic.result) && id(domestic.result?.body?.data?.summary?.orderId) === rootOrderId
|
||||||
|
&& (expectedCompleted ? domestic.result.body.data.summary.mainStatus === 'COMPLETED'
|
||||||
|
: rootDelivered ? domestic.result.body.data.summary.mainStatus === 'DELIVERED'
|
||||||
|
: nonBlank(domestic.result.body.data.summary.mainStatus) && domestic.result.body.data.summary.mainStatus !== 'COMPLETED')
|
||||||
|
context.assert(MODULE, expectedCompleted ? '全部有效子单签收后原始履约根COMPLETED' : '有效余量或根票未签收时原始履约根不得提前COMPLETED',
|
||||||
|
root.response, rawPassed, `rootOrderId=${rootOrderId}, pendingChildren=${pendingChildren.map(row => row.orderNo).join(',')}`, OPTIONS)
|
||||||
|
context.addStep(MODULE, expectedCompleted ? '同订单全部有效履约分片签收后统一订单进入COMPLETED' : '本票签收但有效余量未完时统一订单保留未完成状态',
|
||||||
|
domestic.result, platformPassed, `rootOrderId=${rootOrderId}, pollRequests=${domestic.attempts}, mainStatus=${domestic.result?.body?.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
|
||||||
|
const customer = await merchant(context, merchantToken, 'GET', '客户读取本企业履约根真实完成状态', `/api/merchant/v1/orders/${encodeURIComponent(rootOrderId)}`)
|
||||||
|
const customerPassed = success(customer.response) && id(customer.data?.summary?.orderId) === rootOrderId
|
||||||
|
&& customer.data?.summary?.mainStatus === domestic.result?.body?.data?.summary?.mainStatus && platformPassed
|
||||||
|
context.assert(MODULE, expectedCompleted ? '客户本企业全部履约完成后订单最终状态为COMPLETED' : '客户本企业仍有有效未完成履约时不得展示COMPLETED',
|
||||||
|
customer.response, customerPassed, `rootOrderId=${rootOrderId}, mainStatus=${customer.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
|
||||||
|
if (owned) Object.assign(owned.orderCompletionProof, { rawRootStatus: root.data?.order?.status,
|
||||||
|
platformMainStatus: domestic.result?.body?.data?.summary?.mainStatus, customerMainStatus: customer.data?.summary?.mainStatus,
|
||||||
|
rawPassed: Boolean(rawPassed), platformPassed, customerPassed })
|
||||||
|
return { rootOrderId, expectedCompleted, pendingChildren, passed: Boolean(rawPassed && platformPassed && customerPassed) }
|
||||||
|
}
|
||||||
|
|
||||||
// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof.
|
// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof.
|
||||||
export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) {
|
export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) {
|
||||||
if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode)
|
if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode)
|
||||||
@@ -1012,19 +1108,8 @@ export async function run(context) {
|
|||||||
blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result)
|
blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const domesticOrder = await poll(async () => context.request('GET',
|
|
||||||
query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })),
|
|
||||||
(response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED')
|
|
||||||
context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result,
|
|
||||||
success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED',
|
|
||||||
`orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS)
|
|
||||||
context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId
|
context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId
|
||||||
const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态',
|
await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken })
|
||||||
`/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`)
|
|
||||||
context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response,
|
|
||||||
customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED'
|
|
||||||
&& id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId,
|
|
||||||
trace(customerCompletedOrder.response), OPTIONS)
|
|
||||||
const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken
|
const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken
|
||||||
|| await createSignedDocumentIsolationCustomer(context)
|
|| await createSignedDocumentIsolationCustomer(context)
|
||||||
await verifyCustomerSignedDocuments(context, merchantToken,
|
await verifyCustomerSignedDocuments(context, merchantToken,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate,
|
||||||
|
completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials,
|
||||||
|
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, run } from './phase5-root-fulfillment.mjs'
|
||||||
|
import { loadOneFragment } from './phase3-order-splitting.mjs'
|
||||||
|
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
|
||||||
|
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
|
||||||
|
resolveSharedWarehouse } from './phase4-overseas-pda.mjs'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./phase5-root-fulfillment.mjs', import.meta.url),'utf8')
|
||||||
|
const history = (...actions) => actions.map((action,index) => ({id:String(index+1),action}))
|
||||||
|
const tree = () => ({parentOrderId:'100',originalPackageCount:2,cumulativeLoadedPackageCount:2,
|
||||||
|
currentRemainderPackageCount:0,children:[
|
||||||
|
{orderId:'100',sourceOrderId:'100',splitRole:'LOADED',packageCount:1},
|
||||||
|
{orderId:'101',sourceOrderId:'100',splitRole:'REMAINDER',packageCount:1},
|
||||||
|
{orderId:'101',sourceOrderId:'101',splitRole:'LOADED',packageCount:1},
|
||||||
|
]})
|
||||||
|
const signed = () => ({root:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')},
|
||||||
|
child:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED')},
|
||||||
|
rootInbound:{processStatus:'DELIVERED',orderStatusSnapshot:'COMPLETED'}})
|
||||||
|
const outbox = () => ({readOnly:true,customerId:'90',rootOrderId:'100',orders:['100','101'].map(orderId => ({
|
||||||
|
orderId,customerId:'90',rootOrderId:'100',eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED',
|
||||||
|
count:1,publishedCount:1,pendingCount:0,failedCount:0,
|
||||||
|
}))})
|
||||||
|
const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdResources:{},
|
||||||
|
addStep(_module,_label,_response,passed){this.steps.push({passed})},
|
||||||
|
assert(_module,_label,_response,passed){this.steps.push({passed})},
|
||||||
|
addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data},
|
||||||
|
})
|
||||||
|
|
||||||
|
test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => {
|
||||||
|
const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8')
|
||||||
|
const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'}
|
||||||
|
const now=new Date('2026-09-30T01:02:03.456Z'),body=f40TransferBody('offline',1,vehicle,now)
|
||||||
|
for(const field of ['idempotencyKey','plannedDeliveryAt','deliveryMethod','pickupMethod','loadingType','fees']) {
|
||||||
|
assert.match(dto,new RegExp(`\\b${field}\\b`));assert.ok(body[field])
|
||||||
|
}
|
||||||
|
assert.equal(body.plannedDeliveryAt,'2026-10-02T01:02:03')
|
||||||
|
assert.equal(f40LocalDateTime(now),'2026-09-30T01:02:03')
|
||||||
|
assert.equal(body.deliveryMethod,'TRUCK_PALLET');assert.equal(body.pickupMethod,'WAREHOUSE_PICKUP')
|
||||||
|
assert.equal(body.loadingType,'PALLET');assert.equal(body.vehicleSize,'15托')
|
||||||
|
for(const field of Object.keys(vehicle))assert.equal(body[field],vehicle[field])
|
||||||
|
assert.equal(body.fees.length,1)
|
||||||
|
assert.deepEqual(Object.fromEntries(['feeType','feeName','direction','currency','amount'].map(field=>[field,body.fees[0][field]])),
|
||||||
|
{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10})
|
||||||
|
assert.match(source,/f40TransferBody\(context.marker,index,vehicle\)/)
|
||||||
|
assert.match(source,/estimatedArrivalAt: f40LocalDateTime\(/)
|
||||||
|
})
|
||||||
|
test('F40 credentials prefer a complete current fixture pair and never combine fixture with configured customer',() => {
|
||||||
|
const options={username:'offline-admin',password:'offline-admin-secret',merchantUsername:'configured-customer',merchantPassword:'configured-secret'}
|
||||||
|
const current={username:'fixture-customer',password:'fixture-secret',tenantId:'90'}
|
||||||
|
assert.deepEqual(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:current}}),
|
||||||
|
{username:'fixture-customer',password:'fixture-secret',customerId:'90'})
|
||||||
|
assert.deepEqual(resolveF40MerchantCredentials({options,context:{}}),
|
||||||
|
{username:'configured-customer',password:'configured-secret',customerId:null})
|
||||||
|
for(const incomplete of [{username:'fixture-only',tenantId:'90'},{password:'fixture-only',tenantId:'90'},
|
||||||
|
{username:'fixture-customer',password:'fixture-secret'}])
|
||||||
|
assert.equal(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:incomplete}}),null)
|
||||||
|
assert.equal(resolveF40MerchantCredentials({options:{merchantUsername:'incomplete'},context:{}}),null)
|
||||||
|
assert.ok(source.indexOf("'F40 正式订单创建客户与本轮登录客户一致'") < source.indexOf("await stage(scope, 'inbound-measurement')"))
|
||||||
|
})
|
||||||
|
test('F40 reads formal nested domestic cabinet workbench and compares booking/plan/task identity instead of inventing GET containers/id',() => {
|
||||||
|
const controller=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java',import.meta.url),'utf8')
|
||||||
|
const dto=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java',import.meta.url),'utf8')
|
||||||
|
assert.match(controller,/@GetMapping\("\/domestic\/\{id\}"\)/)
|
||||||
|
assert.doesNotMatch(controller,/@GetMapping\("\/\{id\}"\)/)
|
||||||
|
assert.match(dto,/record WorkbenchItem\(ContainerBookingEntity container, String workbenchStage/)
|
||||||
|
assert.match(source,/`\/api\/platform\/v1\/containers\/domestic\/\$\{result.containerId\}`/)
|
||||||
|
assert.doesNotMatch(source,/`\/api\/platform\/v1\/containers\/\$\{result.containerId\}`/)
|
||||||
|
const fragment={containerId:'20',planId:'30',taskId:'40',oceanBookingId:'50'},task={id:'40',containerBookingId:'20',loadingPlanId:'30'}
|
||||||
|
const workbench={container:{id:'20',cabinetNo:'D0001',containerNo:'OFFLINE',oceanBookingId:'50'}}
|
||||||
|
assert.equal(validFragmentCabinetIdentity(workbench,fragment,task),true)
|
||||||
|
assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,containerId:'21'},task),false)
|
||||||
|
assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,containerBookingId:'21'}),false)
|
||||||
|
assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,loadingPlanId:'31'}),false)
|
||||||
|
assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,id:'41'}),false)
|
||||||
|
assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,oceanBookingId:'51'},task),false)
|
||||||
|
assert.equal(validFragmentCabinetIdentity(workbench.container,fragment,task),false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formal tree requires one root physical source plus its exact real remainder child and conserves two boxes',() => {
|
||||||
|
assert.equal(validTwoFragmentTree(tree(),'100','101'),true)
|
||||||
|
for (const change of [
|
||||||
|
value => value.parentOrderId='999', value => value.currentRemainderPackageCount=1,
|
||||||
|
value => value.children[2].sourceOrderId='999', value => value.children[1].sourceOrderId='999',
|
||||||
|
value => value.children[2].packageCount=2,
|
||||||
|
value => value.children.push({splitRole:'UNKNOWN',orderId:'999'}),
|
||||||
|
]) {const value=tree();change(value);assert.equal(validTwoFragmentTree(value,'100','101'),false)}
|
||||||
|
})
|
||||||
|
test('F40 separates immutable physical task cartons from current actual FBA receiving prefix scans',() => {
|
||||||
|
assert.match(source,/cartonMarks: stableLoadedCartonMarks\(task.items\)/)
|
||||||
|
assert.match(source,/receivingCartonMarks:\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT-1`\]/)
|
||||||
|
assert.doesNotMatch(source,/cartonMarks:\s*\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT/)
|
||||||
|
})
|
||||||
|
test('raw Order and original status history remain in evidence including operator, reason and timestamp',() => {
|
||||||
|
const detail={order:{id:'100',orderRole:'MASTER',version:3,status:'TRANSPORTING'},statusHistory:[
|
||||||
|
{id:'11',orderId:'100',action:'OVERSEAS_DELIVERED',operatorName:'OVERSEAS_DELIVERY_EVENT',
|
||||||
|
reason:'signed actual delivery',occurredAt:'2026-09-30T01:00:00',fromStatus:'TRANSPORTING',toStatus:'TRANSPORTING'},
|
||||||
|
]}
|
||||||
|
const snapshot=fulfillmentHistorySnapshot(detail)
|
||||||
|
assert.deepEqual(snapshot.rawOrder,detail.order)
|
||||||
|
assert.deepEqual(snapshot.histories,detail.statusHistory)
|
||||||
|
assert.deepEqual(completionCounts(snapshot),{own:1,final:0})
|
||||||
|
})
|
||||||
|
test('root shipment signed first preserves unfinished root while effective child remains pending',() => {
|
||||||
|
const snapshot=signed();snapshot.root.status='TRANSPORTING'
|
||||||
|
snapshot.root.histories=history('OVERSEAS_DELIVERED');snapshot.child={status:'TRANSPORTING',histories:[]}
|
||||||
|
snapshot.rootInbound.orderStatusSnapshot='TRANSPORTING'
|
||||||
|
assert.equal(validSignatureGate(snapshot,'MASTER'),true)
|
||||||
|
snapshot.root.status='COMPLETED'
|
||||||
|
assert.equal(validSignatureGate(snapshot,'MASTER'),false)
|
||||||
|
})
|
||||||
|
test('child signed before root remains an independent completed child and cannot finish unsigned root',() => {
|
||||||
|
const snapshot=signed();snapshot.root={status:'TRANSPORTING',histories:[]}
|
||||||
|
snapshot.rootInbound={processStatus:'IN_TRANSIT',orderStatusSnapshot:'TRANSPORTING'}
|
||||||
|
assert.equal(validSignatureGate(snapshot,'CHILD'),true)
|
||||||
|
assert.equal(validSignatureGate(snapshot,'MASTER'),false)
|
||||||
|
snapshot.root.histories=history('OVERSEAS_DELIVERED')
|
||||||
|
assert.equal(validSignatureGate(snapshot,'CHILD'),false)
|
||||||
|
})
|
||||||
|
test('all signed requires root own fact, exactly one aggregate final history and real Firstleg delivered completion',() => {
|
||||||
|
assert.equal(validSignatureGate(signed(),'MASTER',true),true)
|
||||||
|
for(const change of [
|
||||||
|
value => value.root.histories=history('FULFILLMENT_COMPLETED'),
|
||||||
|
value => value.root.histories.push({action:'FULFILLMENT_COMPLETED'}),
|
||||||
|
value => value.child.histories.push({action:'OVERSEAS_DELIVERED'}),
|
||||||
|
value => value.child.status='CANCELLED',
|
||||||
|
value => value.rootInbound.orderStatusSnapshot='TRANSPORTING',
|
||||||
|
value => value.rootInbound.processStatus='RECEIVED',
|
||||||
|
]) {const value=signed();change(value);assert.equal(validSignatureGate(value,'MASTER',true),false)}
|
||||||
|
})
|
||||||
|
test('Outbox requires exact customer/root/source aggregate membership, unique completion and PUBLISHED',() => {
|
||||||
|
assert.equal(exactCompletionOutboxProof(outbox(),'90','100',['101','100']),true)
|
||||||
|
for(const change of [
|
||||||
|
value => value.readOnly=false, value => value.customerId='91', value => value.rootOrderId='999',
|
||||||
|
value => value.orders[0].rootOrderId='999', value => value.orders[0].customerId='91',
|
||||||
|
value => value.orders[1].orderId='100', value => value.orders[0].count=2,
|
||||||
|
value => value.orders[1].publishedCount=0, value => value.orders[1].pendingCount=1,
|
||||||
|
value => value.orders[0].failedCount=1, value => value.orders[0].eventType='OVERSEAS_DELIVERED',
|
||||||
|
value => value.orders.pop(),
|
||||||
|
]) {const value=outbox();change(value);assert.equal(exactCompletionOutboxProof(value,'90','100',['100','101']),false)}
|
||||||
|
})
|
||||||
|
test('exported fragment producer preserves fail closed supplier precondition and never writes without actual source master',async() => {
|
||||||
|
const context=fixture(),calls=[]
|
||||||
|
context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}}
|
||||||
|
assert.equal(await loadOneFragment(context,{sourceOrderNo:'offline-only'}),null)
|
||||||
|
assert.equal(calls.length,1)
|
||||||
|
assert.equal(calls[0][0],'GET')
|
||||||
|
assert.match(calls[0][1],/category=BOOKING&status=ACTIVE/)
|
||||||
|
})
|
||||||
|
test('exported pallet label helper preserves FBA eligibility without introducing writes for other destinations',async() => {
|
||||||
|
const context=fixture();context.request=async() => {throw new Error('unexpected HTTP')}
|
||||||
|
assert.equal(await verifyPalletLabels(context,{destinationType:'OVERSEAS'},'1'),null)
|
||||||
|
assert.equal(await verifyPalletLabels(context,{destinationType:'FBA',fulfillmentType:'ONE_CLICK_DROPSHIP'},'1'),null)
|
||||||
|
})
|
||||||
|
test('exported consumer polling and conversion fail closed when real producer consumption is absent',async() => {
|
||||||
|
const context=fixture(),calls=[]
|
||||||
|
context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}}
|
||||||
|
assert.equal(await awaitProducerConsumption(context,[]),true)
|
||||||
|
assert.equal(await convertReceivingContainers(context,[{containerNo:'OFFLINE'}]),false)
|
||||||
|
assert.equal(calls.every(([method]) => method==='GET'),true)
|
||||||
|
assert.equal(context.steps.at(-1).passed,false)
|
||||||
|
})
|
||||||
|
test('exported receiving rejects unmatched current producer instead of borrowing any history container',async() => {
|
||||||
|
const context=fixture();context.request=async(_method,path) => ({http:200,body:{code:200,data:
|
||||||
|
path.endsWith('/candidates')?[{containerNo:'ANOTHER',sourceContainerBookingId:'999',totalPackages:1}]:null}})
|
||||||
|
await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false)
|
||||||
|
assert.equal(context.steps.some(step => step.passed === false),true)
|
||||||
|
})
|
||||||
|
test('exported receiving warehouse resolver continues to choose only configured active official warehouse',async() => {
|
||||||
|
const context=fixture();context.request=async() => ({http:200,body:{code:200,data:{records:[
|
||||||
|
{id:'193000000000200003',warehouseType:'OVERSEAS',status:'ACTIVE',warehouseName:'offline'},
|
||||||
|
]}}})
|
||||||
|
assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'})
|
||||||
|
})
|
||||||
|
test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback, SQL or cancellation substitute',() => {
|
||||||
|
assert.equal(typeof run,'function')
|
||||||
|
for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch',
|
||||||
|
'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`))
|
||||||
|
for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption',
|
||||||
|
'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','saveReceiptAndComplete'])
|
||||||
|
assert.match(source,new RegExp(`await ${name}\\(`))
|
||||||
|
assert.match(source,/rootOrderId, sourceOrderIds:\[rootOrderId,childOrderId\]/)
|
||||||
|
assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/)
|
||||||
|
assert.match(source,/finally \{ proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/)
|
||||||
|
assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/cancel|\/complete-delivery/)
|
||||||
|
})
|
||||||
|
test('early F40 failures become FAILED with only a safe stage and preserve the original exception',async() => {
|
||||||
|
const context=fixture();context.context={};context.options={}
|
||||||
|
await assert.rejects(run(context),/F40 本轮客户完整凭据有效/)
|
||||||
|
assert.equal(context.createdResources.phase5RootFulfillment.finalStatus,'FAILED')
|
||||||
|
assert.equal(context.createdResources.phase5RootFulfillment.failedStage,'CUSTOMER_IDENTITY')
|
||||||
|
assert.equal('error' in context.createdResources.phase5RootFulfillment,false)
|
||||||
|
const secretError=new Error('offline-secret-body-not-recorded')
|
||||||
|
const withLogin=fixture();withLogin.context={};withLogin.options={merchantUsername:'offline',merchantPassword:'offline'}
|
||||||
|
withLogin.request=async()=>{throw secretError}
|
||||||
|
await assert.rejects(run(withLogin),error=>error === secretError)
|
||||||
|
assert.equal(withLogin.createdResources.phase5RootFulfillment.finalStatus,'FAILED')
|
||||||
|
assert.equal(JSON.stringify(withLogin.createdResources.phase5RootFulfillment).includes(secretError.message),false)
|
||||||
|
})
|
||||||
@@ -14,6 +14,40 @@ const id = value => value == null ? '' : String(value)
|
|||||||
const success = response => response?.http === 200 && String(response?.body?.code) === '200'
|
const success = response => response?.http === 200 && String(response?.body?.code) === '200'
|
||||||
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
|
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
|
||||||
|
|
||||||
|
export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
|
||||||
|
export function resolveF40MerchantCredentials(context) {
|
||||||
|
const fixture = context.context?.merchantQualificationFixture
|
||||||
|
if (!fixture) {
|
||||||
|
const source = context.options
|
||||||
|
if (!source?.merchantUsername || !source?.merchantPassword) return null
|
||||||
|
return { username:source.merchantUsername, password:source.merchantPassword, customerId:null }
|
||||||
|
}
|
||||||
|
if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null
|
||||||
|
return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) }
|
||||||
|
}
|
||||||
|
export function f40TransferBody(marker, index, vehicle, now = new Date()) {
|
||||||
|
return {
|
||||||
|
idempotencyKey:`${marker}-F40-TRANSFER-${index}`,
|
||||||
|
plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)),
|
||||||
|
deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64),
|
||||||
|
carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET',
|
||||||
|
vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName,
|
||||||
|
driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate,
|
||||||
|
appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`,
|
||||||
|
fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10,
|
||||||
|
remark:`${marker}-acceptance`}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function validFragmentCabinetIdentity(workbench, fragment, task) {
|
||||||
|
const container = workbench?.container
|
||||||
|
return id(container?.id) === id(fragment?.containerId)
|
||||||
|
&& Boolean(container?.cabinetNo && container?.containerNo)
|
||||||
|
&& id(container?.oceanBookingId) === id(fragment?.oceanBookingId)
|
||||||
|
&& id(task?.id) === id(fragment?.taskId)
|
||||||
|
&& id(task?.containerBookingId) === id(fragment?.containerId)
|
||||||
|
&& id(task?.loadingPlanId) === id(fragment?.planId)
|
||||||
|
}
|
||||||
|
|
||||||
export function fulfillmentHistorySnapshot(detail) {
|
export function fulfillmentHistorySnapshot(detail) {
|
||||||
return {
|
return {
|
||||||
orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId),
|
orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId),
|
||||||
@@ -124,9 +158,12 @@ async function fragment(context, source, rootOrderId, sequence) {
|
|||||||
check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`)
|
check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`)
|
||||||
const current = await api(context, 'GET', '回查该片真实入仓来源',
|
const current = await api(context, 'GET', '回查该片真实入仓来源',
|
||||||
query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) }))
|
query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) }))
|
||||||
const container = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/${result.containerId}`)
|
const workbench = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/domestic/${result.containerId}`)
|
||||||
|
const container = workbench?.container
|
||||||
const task = await api(context, 'GET', '回查该片真实完成 PDA 任务',
|
const task = await api(context, 'GET', '回查该片真实完成 PDA 任务',
|
||||||
query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) }))
|
query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) }))
|
||||||
|
check(context,'F40 公开国内柜次详情和实际 PDA 的订舱柜次计划任务身份一致',
|
||||||
|
validFragmentCabinetIdentity(workbench,result,task),`containerBookingId=${result.containerId},planId=${result.planId},taskId=${result.taskId}`)
|
||||||
check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED'
|
check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED'
|
||||||
&& task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1,
|
&& task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1,
|
||||||
`source=${current?.summary?.inboundCode}`)
|
`source=${current?.summary?.inboundCode}`)
|
||||||
@@ -138,6 +175,8 @@ async function fragment(context, source, rootOrderId, sequence) {
|
|||||||
warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo,
|
warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo,
|
||||||
destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId),
|
destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId),
|
||||||
destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items),
|
destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items),
|
||||||
|
// Standard FBA resolves the actual receiving inbound prefix; transferred physical box codes remain unchanged.
|
||||||
|
receivingCartonMarks:[`${current.summary.inboundCode}#PDA-CONTRACT-1`],
|
||||||
finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true,
|
finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true,
|
||||||
}
|
}
|
||||||
context.createdResources.phase3Loading = loading
|
context.createdResources.phase3Loading = loading
|
||||||
@@ -167,14 +206,15 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
|
|||||||
planPalletIds: matches.map(row => id(row.planPalletId)),
|
planPalletIds: matches.map(row => id(row.planPalletId)),
|
||||||
})
|
})
|
||||||
const dispatchNo = dispatch.dispatchNo
|
const dispatchNo = dispatch.dispatchNo
|
||||||
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, {
|
const vehicle = {
|
||||||
vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机',
|
vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机',
|
||||||
driverPhone: '19900000000', carrierName: 'F40测试车队',
|
driverPhone: '19900000000', carrierName: 'F40测试车队',
|
||||||
estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19),
|
estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)),
|
||||||
})
|
}
|
||||||
|
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle)
|
||||||
const transfer = await api(context, 'POST', '客户正式创建该片派送单',
|
const transfer = await api(context, 'POST', '客户正式创建该片派送单',
|
||||||
query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }),
|
query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }),
|
||||||
{ idempotencyKey: `${context.marker}-F40-TRANSFER-${index}`, remark: context.marker }, merchantToken)
|
f40TransferBody(context.marker,index,vehicle), merchantToken)
|
||||||
const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : []
|
const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : []
|
||||||
check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`)
|
check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`)
|
||||||
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId })
|
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId })
|
||||||
@@ -200,15 +240,29 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
|
|||||||
check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`)
|
check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`)
|
||||||
const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index)
|
const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index)
|
||||||
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
|
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
|
||||||
|
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
|
||||||
return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo,
|
return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo,
|
||||||
receiptVersion: completed.order.version }
|
receiptVersion: completed.order.version, signedAt: completed.order.signedAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
|
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
|
||||||
export async function run(context) {
|
export async function run(context) {
|
||||||
|
try { return await runRootFulfillment(context) }
|
||||||
|
catch (error) {
|
||||||
|
const proof = context.createdResources?.phase5RootFulfillment
|
||||||
|
if (proof) {
|
||||||
|
proof.finalStatus = 'FAILED'
|
||||||
|
proof.failedStage ||= proof.currentStage || 'INITIALIZING'
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function runRootFulfillment(context) {
|
||||||
|
const initialStepCount = context.steps?.length || 0
|
||||||
const proof = context.createdResources.phase5RootFulfillment = {
|
const proof = context.createdResources.phase5RootFulfillment = {
|
||||||
marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null,
|
marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null,
|
||||||
fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null,
|
fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null,
|
||||||
|
currentStage:'INITIALIZING', failedStage:null,
|
||||||
cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence',
|
cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence',
|
||||||
}
|
}
|
||||||
const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST'
|
const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST'
|
||||||
@@ -219,11 +273,26 @@ export async function run(context) {
|
|||||||
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
|
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
|
||||||
orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' },
|
orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' },
|
||||||
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
|
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
|
||||||
|
phase4OverseasPdaOperations: { completedFixtures: [] },
|
||||||
allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` }
|
allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` }
|
||||||
scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } }
|
scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } }
|
||||||
|
proof.currentStage = 'CUSTOMER_IDENTITY'
|
||||||
|
const credentials = resolveF40MerchantCredentials(scope)
|
||||||
|
check(scope,'F40 本轮客户完整凭据有效', Boolean(credentials), 'complete-credential-pair')
|
||||||
|
const login = await api(scope,'POST','F40 本轮客户正式登录','/api/auth/login',
|
||||||
|
{loginId:credentials.username,password:credentials.password},'')
|
||||||
|
const profile = await api(scope,'GET','F40 本轮客户会话归属核验',
|
||||||
|
'/api/merchant/v1/iam/tenant/profile',undefined,login.accessToken)
|
||||||
|
const expectedCustomerId = id(profile.id)
|
||||||
|
check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId)
|
||||||
|
&& (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer')
|
||||||
await domesticLogin(scope)
|
await domesticLogin(scope)
|
||||||
|
proof.currentStage = 'ORDER_CREATION'
|
||||||
await stage(scope, 'orders')
|
await stage(scope, 'orders')
|
||||||
const orderFixture = { ...scope.createdResources.orders }
|
const orderFixture = { ...scope.createdResources.orders }
|
||||||
|
check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId,
|
||||||
|
'actual-order-customer-matches-profile')
|
||||||
|
proof.currentStage = 'DOMESTIC_MEASUREMENT'
|
||||||
await stage(scope, 'inbound-measurement')
|
await stage(scope, 'inbound-measurement')
|
||||||
const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId)
|
const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId)
|
||||||
proof.rootOrderId = rootOrderId; proof.customerId = customerId
|
proof.rootOrderId = rootOrderId; proof.customerId = customerId
|
||||||
@@ -231,8 +300,10 @@ export async function run(context) {
|
|||||||
query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId }))
|
query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId }))
|
||||||
check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT'
|
check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT'
|
||||||
&& Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`)
|
&& Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`)
|
||||||
|
proof.currentStage = 'ROOT_LOAD_AND_SHIP'
|
||||||
const first = await fragment(scope, rootInbound, rootOrderId, 0)
|
const first = await fragment(scope, rootInbound, rootOrderId, 0)
|
||||||
proof.fragments.push(first)
|
proof.fragments.push(first)
|
||||||
|
proof.currentStage = 'REMAINDER_IDENTITY'
|
||||||
const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树',
|
const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树',
|
||||||
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
|
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
|
||||||
const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || []
|
const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || []
|
||||||
@@ -253,8 +324,10 @@ export async function run(context) {
|
|||||||
const childInbound = await api(scope, 'GET', '读取子单可装柜来源',
|
const childInbound = await api(scope, 'GET', '读取子单可装柜来源',
|
||||||
query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId }))
|
query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId }))
|
||||||
scope.marker = `${scope.context.f40BaseMarker}-CHILD`
|
scope.marker = `${scope.context.f40BaseMarker}-CHILD`
|
||||||
|
proof.currentStage = 'CHILD_LOAD_AND_SHIP'
|
||||||
const second = await fragment(scope, childInbound, rootOrderId, 1)
|
const second = await fragment(scope, childInbound, rootOrderId, 1)
|
||||||
proof.fragments.push(second)
|
proof.fragments.push(second)
|
||||||
|
proof.currentStage = 'FULFILLMENT_TREE'
|
||||||
const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树',
|
const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树',
|
||||||
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
|
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
|
||||||
check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId)
|
check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId)
|
||||||
@@ -264,26 +337,25 @@ export async function run(context) {
|
|||||||
&& id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED'
|
&& id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED'
|
||||||
&& before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures')
|
&& before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures')
|
||||||
proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before })
|
proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before })
|
||||||
|
proof.currentStage = 'OVERSEAS_PDA_IDENTITY'
|
||||||
const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope)
|
const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope)
|
||||||
check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent')
|
check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent')
|
||||||
scope.marker = `${scope.context.f40BaseMarker}-PDA`
|
scope.marker = `${scope.context.f40BaseMarker}-PDA`
|
||||||
const picker = await createPicker(scope, 7, warehouse.id, access)
|
const picker = await createPicker(scope, 7, warehouse.id, access)
|
||||||
|
try {
|
||||||
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
|
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
|
||||||
picker.warehouseId = warehouse.id
|
picker.warehouseId = warehouse.id
|
||||||
try {
|
|
||||||
const loginId = scope.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME
|
|
||||||
const password = scope.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
|
|
||||||
const login = await api(scope, 'POST', 'F40 客户正式登录', '/api/auth/login', { loginId, password }, '')
|
|
||||||
const profile = await api(scope, 'GET', 'F40 客户会话归属核验', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken)
|
|
||||||
check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned')
|
check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned')
|
||||||
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] }
|
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] }
|
||||||
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
|
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
|
||||||
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
|
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
|
||||||
overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null }
|
overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null }
|
||||||
|
proof.currentStage = 'OVERSEAS_PLAN_CONVERSION'
|
||||||
check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed')
|
check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed')
|
||||||
check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted')
|
check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted')
|
||||||
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second]
|
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second]
|
||||||
for (const [index, producer] of signatureSources.entries()) {
|
for (const [index, producer] of signatureSources.entries()) {
|
||||||
|
proof.currentStage = `OVERSEAS_RECEIVING_${index+1}`
|
||||||
scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}`
|
scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}`
|
||||||
scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker
|
scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker
|
||||||
scope.createdResources.phase4OverseasPda.marker = scope.marker
|
scope.createdResources.phase4OverseasPda.marker = scope.marker
|
||||||
@@ -295,15 +367,21 @@ export async function run(context) {
|
|||||||
&& scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row =>
|
&& scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row =>
|
||||||
id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo),
|
id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo),
|
||||||
`container=${producer.containerNo}`)
|
`container=${producer.containerNo}`)
|
||||||
|
proof.currentStage = `DISPATCH_AND_SIGNATURE_${index+1}`
|
||||||
const signed = await sendFragment(scope,producer,picker,login.accessToken,index)
|
const signed = await sendFragment(scope,producer,picker,login.accessToken,index)
|
||||||
proof.signatures.push(signed)
|
proof.signatures.push(signed)
|
||||||
|
proof.currentStage = `COMPLETION_GATE_${index+1}`
|
||||||
const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId),
|
const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId),
|
||||||
value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1))
|
value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1))
|
||||||
proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed })
|
proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed })
|
||||||
check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次',
|
check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次',
|
||||||
validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder)
|
validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder)
|
||||||
}
|
}
|
||||||
|
proof.currentStage = 'RECEIPT_REPLAY'
|
||||||
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
|
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
|
||||||
|
const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1)
|
||||||
|
check(scope,'F40 根完成时间为实际两片签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt,
|
||||||
|
`completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`)
|
||||||
// Existing receipt helper replays the exact original save command before each signature observation.
|
// Existing receipt helper replays the exact original save command before each signature observation.
|
||||||
// Re-saving completed receipts with a fresh business key is also a no-transition operation.
|
// Re-saving completed receipts with a fresh business key is also a no-transition operation.
|
||||||
for (const [index,signed] of proof.signatures.entries()) {
|
for (const [index,signed] of proof.signatures.entries()) {
|
||||||
@@ -320,6 +398,7 @@ export async function run(context) {
|
|||||||
check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增',
|
check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增',
|
||||||
JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot')
|
JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot')
|
||||||
proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay})
|
proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay})
|
||||||
|
proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE'
|
||||||
const reader = context.context?.f40ReadOnlyOutboxEvidence
|
const reader = context.context?.f40ReadOnlyOutboxEvidence
|
||||||
if (typeof reader === 'function') {
|
if (typeof reader === 'function') {
|
||||||
proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId],
|
proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId],
|
||||||
@@ -336,7 +415,15 @@ export async function run(context) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
proof.finalStatus = 'FAILED'
|
proof.finalStatus = 'FAILED'
|
||||||
|
proof.failedStage = proof.currentStage
|
||||||
throw error
|
throw error
|
||||||
} finally { await disablePicker(scope,picker,7) }
|
} finally { proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) }
|
||||||
|
if (proof.finalStatus === 'VERIFIED') {
|
||||||
|
const failedSteps = (context.steps || []).slice(initialStepCount).filter(row => row.passed === false)
|
||||||
|
if (failedSteps.length) proof.finalStatus = 'FAILED'
|
||||||
|
check(context,'F40 正式链、重放及临时PDA停用所有检查均通过', failedSteps.length === 0,
|
||||||
|
`failed=${failedSteps.length}`)
|
||||||
|
}
|
||||||
|
proof.currentStage = 'FINISHED'
|
||||||
return proof
|
return proof
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ function sqlStrings(values) {
|
|||||||
return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',')
|
return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseMetadataRows(output) {
|
||||||
|
return output.trim().split(/\r?\n/).filter(Boolean).map((line) => {
|
||||||
|
if (!/^(?:[0-9a-f]{2})+$/i.test(line)) throw new Error('数据库元数据输出不是有效 HEX JSON')
|
||||||
|
return JSON.parse(Buffer.from(line, 'hex').toString('utf8'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function runMysql(config, sql) {
|
function runMysql(config, sql) {
|
||||||
const executable = process.env.MYSQL_BIN || 'mysql'
|
const executable = process.env.MYSQL_BIN || 'mysql'
|
||||||
const result = spawnSync(executable, [
|
const result = spawnSync(executable, [
|
||||||
@@ -70,14 +77,14 @@ function runMysql(config, sql) {
|
|||||||
})
|
})
|
||||||
if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`)
|
if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`)
|
||||||
if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`)
|
if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`)
|
||||||
return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
|
return parseMetadataRows(result.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(config, schemas) {
|
function snapshot(config, schemas) {
|
||||||
const selected = sqlStrings(schemas)
|
const selected = sqlStrings(schemas)
|
||||||
const schemaRows = runMysql(config, `SELECT JSON_OBJECT('schema', SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`)
|
const schemaRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', SCHEMA_NAME)) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`)
|
||||||
const tableRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, '')) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`)
|
const tableRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, ''))) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`)
|
||||||
const columnRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`)
|
const columnRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION)) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`)
|
||||||
return { schemas: schemaRows, tables: tableRows, columns: columnRows }
|
return { schemas: schemaRows, tables: tableRows, columns: columnRows }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { compareSnapshots } from './database-schema-diff.mjs'
|
import { compareSnapshots, parseMetadataRows } from './database-schema-diff.mjs'
|
||||||
|
|
||||||
|
test('metadata transport preserves Unicode, escaped control characters and expression defaults', () => {
|
||||||
|
const rows = [{ schema: 'sample', table: 'orders', defaultValue: '国内\r\n海外\t"quote"\\path', generation: "concat('x', '\\n')" }, { defaultValue: null }]
|
||||||
|
const output = rows.map(row => Buffer.from(JSON.stringify(row), 'utf8').toString('hex')).join('\r\n') + '\r\n'
|
||||||
|
assert.deepEqual(parseMetadataRows(output), rows)
|
||||||
|
assert.deepEqual(parseMetadataRows(''), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('metadata transport rejects invalid or truncated records', () => {
|
||||||
|
assert.throws(() => parseMetadataRows('abc'), /HEX JSON/)
|
||||||
|
assert.throws(() => parseMetadataRows('not-json'), /HEX JSON/)
|
||||||
|
assert.throws(() => parseMetadataRows(Buffer.from('{"schema":').toString('hex')), SyntaxError)
|
||||||
|
})
|
||||||
|
|
||||||
test('database schema diff ignores column order but catches contract changes', () => {
|
test('database schema diff ignores column order but catches contract changes', () => {
|
||||||
const left = {
|
const left = {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin
|
|||||||
|
|
||||||
const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8')
|
const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8')
|
||||||
|
|
||||||
|
test('F40 optional chain always creates its customer and rejects absent domestic PDA credentials', () => {
|
||||||
|
const options = parseArguments(['--modules', 'phase5-root-fulfillment',
|
||||||
|
'--gateway', 'http://localhost:9000', '--username', 'contract-user', '--password', 'contract-password',
|
||||||
|
'--merchant-username', 'prior-client', '--merchant-password', 'prior-password'])
|
||||||
|
assert.deepEqual(options.modules, ['merchant-qualification-fixture', 'phase5-root-fulfillment'])
|
||||||
|
options.pdaUsername = ''; options.pdaPassword = ''
|
||||||
|
assert.throws(() => validateExecutionOptions(options), /PDA/)
|
||||||
|
})
|
||||||
|
|
||||||
test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => {
|
test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => {
|
||||||
for (const module of [
|
for (const module of [
|
||||||
'phase4-overseas-warehouse',
|
'phase4-overseas-warehouse',
|
||||||
|
|||||||
Reference in New Issue
Block a user