This commit is contained in:
2026-08-13 18:34:44 +08:00
parent f779ab56dd
commit 8cf6709bde
6 changed files with 127 additions and 14 deletions
+1
View File
@@ -19,6 +19,7 @@ x-common: &common
REDIS_DATABASE: ${REDIS_DATABASE:-0}
JWT_SECRET: ${JWT_SECRET}
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events}
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}
AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false}
AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false}
@@ -138,6 +138,24 @@ test('run behavior preserves null IDs and deployment blockers when supplier, vou
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId')))
})
test('run reports missing measurement prerequisites without throwing or borrowing historical fixtures', async () => {
const context = fakeContext()
context.financeFixtureHooks = {
...hookSet(),
measureOrder: async () => null,
}
await run(context)
const fixture = context.createdResources.financeFullFixtures
assert.equal(fixture.orders.length, 0)
assert.deepEqual(fixture.container, { containerBookingId: null, containerNo: null })
assert.deepEqual(fixture.allocations, [])
assert.equal(fixture.prerequisites.container.category, 'container-prerequisite')
assert.ok(fixture.deploymentBlocker.some((item) => item.includes('缺少已完成柜次')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('真实订单/运单分摊')))
})
test('manual fee approval path keeps accountId as a query parameter', () => {
assert.match(source, /basePath = `\/api\/platform\/v1\/inbounds\/.*fee-details`/)
assert.match(source, /\$\{basePath\}\/\$\{feeId\}\/approve\?accountId=/)
@@ -231,6 +231,11 @@ async function loadPricingEvidence(context) {
async function createMarkerContainer(context, resource) {
if (context.financeFixtureHooks?.createMarkerContainer) return context.financeFixtureHooks.createMarkerContainer(context, resource)
if (!resource) {
blocker(context, '取得可创建柜次的测量入库单',
'本轮没有完成测量的 marker-owned 入库单;跳过柜次及整柜成本 fixture,等待订单/测量前置修复')
return null
}
if (context.financeFixtureHooks) return null
const original = context.createdResources.phase3ContainerLoading
const originalMarker = context.marker
@@ -349,7 +354,7 @@ export async function run(context) {
}
}
const container = await createMarkerContainer(context, measured[0])
const container = (measured[0] ? await createMarkerContainer(context, measured[0]) : null)
|| context.createdResources?.phase3ContainerLoading
const containerOwned = (container?.marker === context.marker || String(container?.marker || '').startsWith(`${context.marker}-`))
&& container?.loadingPlanId && container?.containerBookingId
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { approvedInboundReady } from './orders.mjs'
const tenantId = '10001'
const orderId = '20001'
const taskResponse = {
http: 200,
body: { code: '200', data: { id: '30001', tenantId, orderId } },
}
test('draft inbound id alone does not mean approval outbox has converged', () => {
assert.equal(approvedInboundReady({
summary: { inboundId: '40001', mainStatus: 'PREPARING' },
}, taskResponse, tenantId, orderId), false)
})
test('measurable inbound without fulfillment task remains pending', () => {
assert.equal(approvedInboundReady({
summary: { inboundId: '40001', mainStatus: 'WAIT_MEASURE' },
}, { http: 200, body: { code: 'A0404' } }, tenantId, orderId), false)
})
test('approval convergence requires matching inbound state, order and customer task identity', () => {
const detail = { summary: { inboundId: '40001', mainStatus: 'WAIT_MEASURE' } }
assert.equal(approvedInboundReady(detail, taskResponse, tenantId, orderId), true)
assert.equal(approvedInboundReady(detail, {
http: 200, body: { code: '200', data: { id: '30001', tenantId, orderId: '20002' } },
}, tenantId, orderId), false)
assert.equal(approvedInboundReady(detail, {
http: 200, body: { code: '200', data: { id: '30001', tenantId: '10002', orderId } },
}, tenantId, orderId), false)
})
test('orders acceptance polls approval convergence instead of only inbound id visibility', () => {
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
assert.match(source, /审核后入库单与履约任务异步收敛/)
assert.match(source, /approvedInboundReady\(candidate, taskResponse, tenantId, orderId\)/)
assert.match(source, /MEASURABLE_STATUSES\.has\(summary\?\.mainStatus\)/)
assert.doesNotMatch(source, /MEASURABLE_STATUSES\.has\(summary\?\.processStatus\)/)
assert.doesNotMatch(source, /if \(success\(response\) && response\.body\?\.data\?\.summary\?\.inboundId\)/)
})
+32 -13
View File
@@ -16,6 +16,14 @@ function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite
function success(response) { return response.http === 200 && String(response.body?.code) === '200' }
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
const summary = orderDetail?.summary
const task = success(taskResponse) ? taskResponse.body?.data : null
return Boolean(summary?.inboundId)
&& MEASURABLE_STATUSES.has(summary?.mainStatus)
&& id(task?.orderId) === id(orderId)
&& id(task?.tenantId) === id(tenantId)
}
function tomorrow(days = 30) {
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
@@ -202,18 +210,27 @@ async function assertDimensionConfig(context, tenantId) {
async function waitForInbound(context, tenantId, orderId) {
const path = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
let response = null; let detail = null
for (let attempt = 1; attempt <= 20; attempt += 1) {
const taskPath = `/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
let response = null; let detail = null; let taskResponse = null; let task = null
for (let attempt = 1; attempt <= 30; attempt += 1) {
response = await context.request('GET', path)
if (success(response) && response.body?.data?.summary?.inboundId) {
detail = response.body.data
const candidate = success(response) ? response.body?.data : null
if (candidate?.summary?.inboundId) {
taskResponse = await context.request('GET', taskPath)
}
if (approvedInboundReady(candidate, taskResponse, tenantId, orderId)) {
detail = candidate
task = taskResponse.body.data
break
}
if (attempt < 20) await sleep(1_000)
if (attempt < 30) await sleep(1_000)
}
context.addStep(MODULE, '审核后生成隔离入库单', response, Boolean(detail),
detail ? `inboundId=${detail.summary.inboundId}` : '20秒内未返回 inboundId', CONTRACT_OPTIONS)
return { response, detail }
context.addStep(MODULE, '审核后入库单与履约任务异步收敛', response, Boolean(detail && task),
detail
? `inboundId=${detail.summary.inboundId}, status=${detail.summary.mainStatus}, taskId=${id(task?.id)}`
: `30秒内未收敛;status=${response?.body?.data?.summary?.mainStatus ?? 'missing'}, taskCode=${taskResponse?.body?.code ?? 'not-requested'}`,
CONTRACT_OPTIONS)
return { response, detail, taskResponse, task }
}
export async function run(context) {
@@ -320,7 +337,9 @@ export async function run(context) {
const inboundResult = await waitForInbound(context, tenantId, orderId)
const inboundId = id(inboundResult.detail?.summary?.inboundId)
if (!inboundId) throw new Error('订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接')
if (!inboundId || !inboundResult.task) {
throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法向 OCR/墨家模块交接')
}
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`
const inboundResponse = await context.request('GET', inboundPath)
@@ -358,10 +377,10 @@ export async function run(context) {
context.context.testInbound = context.context.inboundWeightOcr
context.context.createdInbound = context.context.inboundWeightOcr
const taskResponse = await context.request('GET',
`/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`)
const task = context.apiSuccess(MODULE, 'Admin 查询本轮订单履约任务', taskResponse,
'新订单应生成可查询的履约任务', CONTRACT_OPTIONS)
const taskResponse = inboundResult.taskResponse
const task = inboundResult.task
context.apiSuccess(MODULE, 'Admin 查询本轮订单履约任务', taskResponse,
'审批 Outbox 收敛后应生成可查询的履约任务', CONTRACT_OPTIONS)
context.assert(MODULE, '履约任务与本轮订单和客户一致', taskResponse,
id(task?.orderId) === orderId && id(task?.tenantId) === tenantId,
`taskId=${id(task?.id) || 'missing'}, status=${task?.status ?? 'missing'}`, CONTRACT_OPTIONS)
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n')
test('基础 Compose 显式配置审计服务地址,避免静默退化为日志输出', async () => {
const compose = await read('deploy/docker-compose.yml')
assert.match(compose,
/BAIYUE_AUDIT_ENDPOINT: \$\{BAIYUE_AUDIT_ENDPOINT:-http:\/\/audit-service:9700\/internal\/audit\/events\}/)
assert.doesNotMatch(compose, /^\s+AUDIT_SERVICE_URL:/m)
assert.match(compose, /^ audit-service:\s*$/m)
assert.match(compose, /container_name: firstleg-\$\{DEPLOY_ENV:-test\}-audit/)
assert.match(compose, /networks: \[firstleg-network, infra-network\]/)
})
test('可靠审计仍由独立 Compose 显式启用,不强制基础环境依赖 RocketMQ', async () => {
const baseCompose = await read('deploy/docker-compose.yml')
const reliableCompose = await read('deploy/docker-compose.audit.yml')
assert.match(baseCompose, /AUDIT_OUTBOX_ENABLED: \$\{AUDIT_OUTBOX_ENABLED:-false\}/)
assert.match(reliableCompose, /AUDIT_OUTBOX_ENABLED: "true"/)
assert.match(reliableCompose, /AUDIT_MQ_CONSUMER_ENABLED: "true"/)
})