This commit is contained in:
2026-08-13 14:37:51 +08:00
parent bc62650ca9
commit 65cd7e0a39
63 changed files with 380 additions and 226 deletions
@@ -403,6 +403,14 @@ export async function run(context) {
'使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS)
context.createdResources.financeFullFixtures = {
marker: context.marker, customerId,
prerequisites: {
container: containerOwned && Boolean(containerFixture?.feeSourceId) && allocations.length > 0
? { ready: true, category: null }
: { ready: false, category: 'container-prerequisite', evidence: '缺少同 marker 已完成柜次、有效 containerBookingId、按柜费用来源或真实分摊' },
financeSources: fixtures.length > 0
? { ready: true, category: null }
: { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生可消费 Finance 费用来源' },
},
orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
shipmentId: item.shipmentId ? id(item.shipmentId) : null,
waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId),
@@ -323,20 +323,25 @@ export async function run(context) {
finish(context, start)
return
}
fixture.root.reconciliationPrerequisite = null
const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions')
const permissions = context.apiSuccess(MODULE, '查询当前账号 Finance 有效权限', permissionResponse, '', OPTIONS)
const granted = new Set(list(permissions).map(String))
if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] }
blocker(context, '客户对账单旧 view 权限兼容阻断',
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据`,
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`,
'legacy-customer-statement-view-permission', permissionResponse)
finish(context, start)
return
}
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
if (missingPermissions.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: missingPermissions, legacy: [] }
blocker(context, 'Finance 写权限门禁',
`缺少权限=${missingPermissions.join(',')}; 对账接口将返回 A0403,禁止继续写入`,
`缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`,
'missing-finance-write-permissions', permissionResponse)
finish(context, start)
return
@@ -353,6 +358,8 @@ export async function run(context) {
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !source.waybillId || source.marker !== context.marker)
if (invalid.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
invalidSourceCount: invalid.length }
blocker(context, 'Fixture 来源契约完整',
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`,
'invalid-finance-fixture-contract')
@@ -364,6 +371,8 @@ export async function run(context) {
&& source.direction === 'RECEIVABLE' && source.currency === 'CNY')
const businessDates = new Set(receivables.map((source) => source.businessDate))
if (receivables.length < 4 || businessDates.size < 2) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
receivableCount: receivables.length, businessDateCount: businessDates.size }
blocker(context, '三张 AR 与日期范围 Fixture 门禁',
`至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`,
'insufficient-receivable-date-fixture')
@@ -125,6 +125,9 @@ test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', as
assert.equal(empty.gaps.length, 1)
assert.equal(empty.steps[0].passed, false)
assert.match(empty.steps[0].evidence, /actual AR=0, AP=0/)
assert.match(empty.steps[0].evidence, /上游 Finance fixture 前置条件未满足,非结算服务故障/)
assert.match(empty.gaps[0][4], /prerequisite/)
assert.equal(empty.calls.some((call) => call.method === 'POST'), false)
const partial = fakeContext({ receivableSettlements: [
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
@@ -133,4 +136,6 @@ test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', as
assert.equal(partial.calls.length, 0)
assert.equal(partial.gaps.length, 1)
assert.match(partial.steps[0].evidence, /actual AR=1, AP=1/)
assert.match(partial.steps[0].evidence, /receivable-settlement-prerequisite/)
assert.equal(partial.calls.some((call) => call.method === 'POST'), false)
})
@@ -128,9 +128,17 @@ export async function run(context) {
const arSettlementIds = new Set(validReceivables.map(settlementId))
const arStatementIds = new Set(validReceivables.map(statementId))
if (validReceivables.length !== 3 || arSettlementIds.size !== 3 || arStatementIds.size !== 3 || validPayables.length !== 1) {
const upstream = fixtures.reconciliationPrerequisite?.ready === false
? fixtures.reconciliationPrerequisite
: Object.values(fixtures.prerequisites || {}).find((item) => item?.ready === false)
const missing = []
if (statements.filter((item) => item.status === 'ISSUED').length < 3) missing.push('reconciliation-issued-statement-prerequisite')
if (validReceivables.length !== 3) missing.push('receivable-settlement-prerequisite')
if (validPayables.length !== 1) missing.push('payable-settlement-prerequisite')
const category = upstream?.category || (missing.length === 1 ? missing[0] : 'upstream-finance-fixture-prerequisite')
gap(context, '取得完整收付结算 Fixture', 'three AR and one AP settlements',
`需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}`,
'incomplete-settlement-fixture')
`上游 Finance fixture 前置条件未满足,非结算服务故障;需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}; prerequisite=${category}; missing=${missing.join(',') || 'unknown'}`,
category)
return
}
@@ -24,6 +24,19 @@ function validPdf(response) {
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
}
async function waitForCompletedMeasurement(context, measurementPath, attempts = 30) {
let response = null
let measurement = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET', measurementPath)
measurement = response?.http === 200 && String(response?.body?.code) === '200'
? response.body.data : null
if (measurement?.processStatus === 'WAIT_SPLIT') return { response, measurement, attempts: attempt }
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { response, measurement, attempts }
}
async function provisionWarehouseResources(context, { inboundId, warehouseId, totalVolumeCbm }) {
const palletCount = Math.ceil(Number(totalVolumeCbm) / DEFAULT_PALLET_CAPACITY_CBM)
if (!warehouseId || !Number.isFinite(palletCount) || palletCount < 1) return false
@@ -394,8 +407,13 @@ export async function run(context) {
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
const finalResponse = await context.request('GET', measurementPath)
const finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse, '', OPTIONS)
// Measurement approval may enqueue pricing/inbound completion. Poll the
// public projection instead of treating the transient WAIT_PRICING state as
// a smart-loading candidate or manufacturing fixture state in the script.
const completed = await waitForCompletedMeasurement(context, measurementPath)
const finalResponse = completed.response
const finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse,
`pollAttempts=${completed.attempts}`, OPTIONS)
context.assert(MODULE, '审核后入库与测量状态闭环且结果未变化', finalResponse,
finalMeasurement?.processStatus === 'WAIT_SPLIT' && finalMeasurement?.measureReviewStatus === 'APPROVED'
&& finalMeasurement?.measurementStatus === 'APPROVED'
@@ -407,6 +425,7 @@ export async function run(context) {
context.createdResources.inboundMeasurement = {
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
}
finishScenario(context, start)
}
@@ -34,14 +34,20 @@ async function request(context, method, path, body, headers = {}) {
}
}
function fixture(context) {
return context.createdResources?.orchestrationFixtures?.phase3Container
|| context.createdResources?.inboundMeasurement
|| context.createdResources?.orders
export function selectContainerLoadingFixture(context) {
const candidates = [
context.createdResources?.orchestrationFixtures?.phase3Container,
context.createdResources?.inboundMeasurement,
context.createdResources?.orders,
].filter(Boolean)
// Finance and downstream modules temporarily switch context.marker for
// their own isolated runs. Never select the older CAB fixture merely because
// it is first in the object; ownership is the marker contract.
return candidates.find((candidate) => candidate.marker === context.marker) || candidates[0]
}
export async function run(context) {
const source = fixture(context)
const source = selectContainerLoadingFixture(context)
const tenantId = id(source?.tenantId)
const inboundId = id(source?.inboundId)
if (!tenantId || !inboundId || source?.marker !== context.marker) {
@@ -4,6 +4,7 @@ import test from 'node:test'
import { selectCustomsFlow } from './phase3-customs.mjs'
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
import { selectContainerLoadingFixture } from './phase3-container-loading.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
function source(name) {
@@ -126,6 +127,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
test('container loading creates and confirms booking before scoped candidates', () => {
const value = source('./phase3-container-loading.mjs')
assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/)
assertOrder(value, [
"const bookingCreate = await request",
"const submit = await request",
@@ -147,6 +149,33 @@ test('container loading creates and confirms booking before scoped candidates',
assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/)
})
test('container loading selects the current marker instead of a stale CAB fixture', () => {
const selected = selectContainerLoadingFixture({
marker: 'ADMIN-RUN-AUTO1',
createdResources: {
orchestrationFixtures: {
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
},
inboundMeasurement: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-INBOUND' },
orders: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-ORDER-INBOUND' },
},
})
assert.equal(selected.inboundId, 'FINANCE-INBOUND')
assert.equal(selected.marker, 'ADMIN-RUN-AUTO1')
})
test('container loading keeps an unmatched candidate so marker failure stays observable', () => {
const selected = selectContainerLoadingFixture({
marker: 'ADMIN-RUN-AUTO1',
createdResources: {
orchestrationFixtures: {
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
},
},
})
assert.equal(selected.marker, 'ADMIN-RUN-CAB')
})
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
const value = source('./phase3-container-loading.mjs')
assertOrder(value, [
@@ -269,6 +298,20 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
assert.equal(run.marker.includes('-LOAD'), false)
})
test('container fixture keeps the run marker and does not publish incomplete WAIT_PRICING resources', () => {
const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /const fixtureMarker = originalMarker/)
assert.match(entry, /fixture\.finalStatus !== 'APPROVED'/)
assert.match(entry, /禁止回退使用 WAIT_PRICING 订单/)
})
test('measurement fixture polls asynchronous completion before exporting loading identity', () => {
const measurement = source('./inbound-measurement.mjs')
assert.match(measurement, /waitForCompletedMeasurement/)
assert.match(measurement, /measurement\?\.processStatus === 'WAIT_SPLIT'/)
assert.match(measurement, /setTimeout\(resolve, 1_000\)/)
})
test('customs and finalize consume the same loading inbound identity', () => {
const loadingFixture = { marker: 'RUN-1', tenantId: 10001, orderId: 101, inboundId: 201 }
const loading = {