From 8cf6709bde9f9b9adb3cb248944591e6f9a75619 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 13 Aug 2026 18:34:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.yml | 1 + .../finance-full-fixtures-contract.test.mjs | 18 ++++++++ .../finance-full-fixtures.mjs | 7 ++- ...ers-approval-convergence-contract.test.mjs | 44 ++++++++++++++++++ scripts/admin-test-env-modules/orders.mjs | 45 +++++++++++++------ scripts/audit-deployment-contract.test.mjs | 26 +++++++++++ 6 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs create mode 100644 scripts/audit-deployment-contract.test.mjs diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e8f8ef29f..aac92b985 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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} diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 7de836e32..f2618e810 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -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=/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 39e5556ab..5f42e2494 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs b/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs new file mode 100644 index 000000000..cb4e9ea60 --- /dev/null +++ b/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs @@ -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\)/) +}) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index e735e6437..48de6e458 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -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) diff --git a/scripts/audit-deployment-contract.test.mjs b/scripts/audit-deployment-contract.test.mjs new file mode 100644 index 000000000..09dd959af --- /dev/null +++ b/scripts/audit-deployment-contract.test.mjs @@ -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"/) +})