优化
This commit is contained in:
@@ -12,7 +12,19 @@ import process from 'node:process'
|
||||
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||
const DEFAULT_USERNAME = 'platform_admin'
|
||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||
const DEFAULT_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', '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-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', '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-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
|
||||
|
||||
export function normalizeModuleOrder(modules) {
|
||||
const normalized = [...modules]
|
||||
const loadingIndex = normalized.indexOf('phase3-loading')
|
||||
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
||||
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
|
||||
normalized.splice(overviewIndex, 1)
|
||||
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
|
||||
return normalized
|
||||
}
|
||||
|
||||
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
|
||||
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
||||
const CUSTOMER_FIELDS = [
|
||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||
@@ -47,7 +59,7 @@ function usage() {
|
||||
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
export function parseArguments(argv) {
|
||||
if (argv.includes('--help')) { usage(); process.exit(0) }
|
||||
const values = new Map()
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -67,7 +79,7 @@ function parseArguments(argv) {
|
||||
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''),
|
||||
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME,
|
||||
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD,
|
||||
modules: (values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean),
|
||||
modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)),
|
||||
output: values.get('output') || '',
|
||||
}
|
||||
}
|
||||
@@ -638,21 +650,26 @@ export class AcceptanceRun {
|
||||
Number(page?.total) === records.length && records.length < 200,
|
||||
`total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options)
|
||||
const disabled = []
|
||||
const skippedOccupied = []
|
||||
for (const location of locations) {
|
||||
const response = await this.request('PATCH',
|
||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
{ status: 'DISABLED', version: Number(location.version) })
|
||||
const success = response?.http === 200 && String(response?.body?.code) === '200'
|
||||
this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success,
|
||||
`locationId=${location.id}, beforeVersion=${location.version}`, options)
|
||||
const occupiedConflict = response?.http === 200
|
||||
&& String(response?.body?.code) === 'A0409'
|
||||
&& String(response?.body?.message) === '预占或占用中的库位不能停用'
|
||||
this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success || occupiedConflict,
|
||||
`locationId=${location.id}, beforeVersion=${location.version}${occupiedConflict ? ', skipped=active-resource' : ''}`, options)
|
||||
if (success) disabled.push({ id: String(location.id), code: location.locationCode, version: Number(location.version) + 1 })
|
||||
if (occupiedConflict) skippedOccupied.push({ id: String(location.id), code: location.locationCode })
|
||||
}
|
||||
const availableResponse = await this.request('GET',
|
||||
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
||||
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
|
||||
this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
|
||||
Array.isArray(available) && available.length === 0 && disabled.length === locations.length,
|
||||
`disabled=${disabled.length}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
||||
Array.isArray(available) && available.length === 0,
|
||||
`disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
||||
return disabled
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,19 @@ export async function run(context) {
|
||||
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
|
||||
- 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。
|
||||
|
||||
## 近期业务模块与静态合同快速索引
|
||||
|
||||
| 业务链路 | HTTP 模块/共享前置 | 离线静态合同 |
|
||||
|---|---|---|
|
||||
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` |
|
||||
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 三期柜次、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` |
|
||||
|
||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||
|
||||
## 供应商模块
|
||||
|
||||
`suppliers.mjs` 覆盖平台公共供应商的列表、统计、新增、详情、编辑、启停及主要错误边界。执行:
|
||||
|
||||
@@ -25,7 +25,7 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs
|
||||
assert.match(source, /const basePath = `\/api\/platform\/v1\/loading-plans\//)
|
||||
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
|
||||
assert.match(source, /created\?\.marker === context\.marker/)
|
||||
assert.match(source, /\/fees`/)
|
||||
assert.match(source, /fee-details\?accountId=/)
|
||||
assert.match(source, /chargeUnit === 'KG'/)
|
||||
assert.match(source, /chargeUnit === 'CBM'/)
|
||||
})
|
||||
@@ -65,7 +65,7 @@ function hookSet({ missing = false } = {}) {
|
||||
loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }),
|
||||
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
|
||||
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
|
||||
calculateAutomaticFee: async (context, resource) => {
|
||||
loadPublishedAutomaticFee: async (context, resource) => {
|
||||
automaticIndex += 1
|
||||
const weight = automaticIndex % 2 === 1
|
||||
return { feeSourceId: `F-${automaticIndex}`, orderId: resource.orderId, inboundId: resource.inboundId,
|
||||
@@ -162,6 +162,40 @@ test('manual fee approval path keeps accountId as a query parameter', () => {
|
||||
assert.doesNotMatch(source, /fee-details\?accountId=\$\{encodeURIComponent\(resource\.tenantId\)\}`\)/)
|
||||
})
|
||||
|
||||
test('automatic fixture reuses the published review-chain source without recalculating at WAIT_SPLIT', () => {
|
||||
assert.match(source, /loadPublishedAutomaticFee/)
|
||||
assert.match(source, /sourceType === 'FBA_PRICING'/)
|
||||
assert.match(source, /reviewStatus === 'APPROVED'/)
|
||||
assert.match(source, /publicationStatus === 'PUBLISHED'/)
|
||||
assert.match(source, /settlementIncluded === true/)
|
||||
assert.match(source, /const feeSourceId = id\(published\.sourceFeeId\)/)
|
||||
assert.doesNotMatch(source, /\/fees\/calculate/)
|
||||
assert.doesNotMatch(source, /\/fees\/confirm/)
|
||||
assert.doesNotMatch(source, /\/fees\/send/)
|
||||
})
|
||||
|
||||
test('missing published automatic source is a prerequisite blocker and never emits a fixture source', async () => {
|
||||
const context = fakeContext()
|
||||
let automaticReads = 0
|
||||
context.financeFixtureHooks = {
|
||||
...hookSet(),
|
||||
loadPublishedAutomaticFee: async (ctx, resource) => {
|
||||
automaticReads += 1
|
||||
ctx.addStep('finance-full-fixtures', '审核链已生成可结算自动费用来源', null, false,
|
||||
`inboundId=${resource.inboundId}; fixture 不重复试算/确认/发布`, { deploymentBlocker: true })
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
await run(context)
|
||||
|
||||
assert.equal(automaticReads, 4)
|
||||
assert.equal(context.createdResources.financeFullFixtures.feeSources
|
||||
.filter((item) => item.sourceType === 'FBA_PRICING').length, 0)
|
||||
assert.ok(context.steps.some((step) => step.deploymentBlocker
|
||||
&& step.name.includes('可结算自动费用来源')))
|
||||
})
|
||||
|
||||
test('fixture creates four real receivable manual sources before payable source', () => {
|
||||
assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/)
|
||||
assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/)
|
||||
|
||||
@@ -130,11 +130,11 @@ async function createManualFee(context, resource, feeItem, direction, amount, ba
|
||||
chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null }
|
||||
}
|
||||
|
||||
async function calculateAutomaticFee(context, resource) {
|
||||
if (context.financeFixtureHooks?.calculateAutomaticFee) return context.financeFixtureHooks.calculateAutomaticFee(context, resource)
|
||||
async function loadPublishedAutomaticFee(context, resource) {
|
||||
if (context.financeFixtureHooks?.loadPublishedAutomaticFee) return context.financeFixtureHooks.loadPublishedAutomaticFee(context, resource)
|
||||
const pricingInboundResponse = await context.request('GET',
|
||||
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`)
|
||||
const pricingInbound = context.apiSuccess(MODULE, '计费前回读入库主表计费快照', pricingInboundResponse,
|
||||
const pricingInbound = context.apiSuccess(MODULE, '回读审核完成后的入库主表状态', pricingInboundResponse,
|
||||
`inboundId=${resource.inboundId}`, OPTIONS)
|
||||
const pricingSummary = pricingInbound?.summary || {}
|
||||
const actualWeightKg = Number(pricingSummary.actualGrossWeightKg ?? pricingSummary.actualWeightKg)
|
||||
@@ -144,31 +144,33 @@ async function calculateAutomaticFee(context, resource) {
|
||||
`服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`)
|
||||
return null
|
||||
}
|
||||
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees`
|
||||
const query = `tenantId=${encodeURIComponent(resource.tenantId)}`
|
||||
const calculateResponse = await request(context, 'POST', `${base}/calculate?${query}`, {})
|
||||
const calculated = context.apiSuccess(MODULE, '按审核测量快照执行真实自动计费', calculateResponse,
|
||||
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS)
|
||||
const feeSourceId = id(calculated?.feeId)
|
||||
const billingBasis = calculated?.chargeUnit === 'KG' ? 'WEIGHT_KG'
|
||||
: calculated?.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null
|
||||
const responseWeightKg = Number(calculated?.actualWeightKg)
|
||||
const responseVolumeCbm = Number(calculated?.actualVolumeCbm)
|
||||
context.assert(MODULE, '自动费用返回来源、金额和计费依据', calculateResponse,
|
||||
Boolean(feeSourceId) && Boolean(billingBasis) && Number(calculated?.totalAmount) >= 0
|
||||
const listPath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}`
|
||||
const listResponse = await context.request('GET', listPath)
|
||||
const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse,
|
||||
`inboundId=${resource.inboundId}`, OPTIONS)
|
||||
const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING'
|
||||
&& item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED'
|
||||
&& item?.settlementIncluded === true)
|
||||
if (!published) {
|
||||
blocker(context, '审核链已生成可结算自动费用来源',
|
||||
`inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeDetails=${rows(details).length};测量审核完成链应在进入 WAIT_SPLIT 前生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源,fixture 不重复试算/确认/发布`)
|
||||
return null
|
||||
}
|
||||
const feeSourceId = id(published.sourceFeeId)
|
||||
const billingBasis = published.chargeUnit === 'KG' ? 'WEIGHT_KG'
|
||||
: published.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null
|
||||
const responseWeightKg = Number(published.actualGrossWeightKg ?? actualWeightKg)
|
||||
const responseVolumeCbm = Number(published.actualVolumeCbm ?? actualVolumeCbm)
|
||||
context.assert(MODULE, '已发布自动费用来源、金额和计费依据完整', listResponse,
|
||||
Boolean(feeSourceId) && Boolean(billingBasis) && Number(published.amount) >= 0
|
||||
&& responseWeightKg > 0 && responseVolumeCbm > 0,
|
||||
`feeSourceId=${feeSourceId || 'missing'}, chargeUnit=${calculated?.chargeUnit || 'missing'}, amount=${calculated?.totalAmount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS)
|
||||
`feeSourceId=${feeSourceId || 'missing'}, detailId=${id(published.id) || 'missing'}, chargeUnit=${published.chargeUnit || 'missing'}, amount=${published.amount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS)
|
||||
if (!feeSourceId || !billingBasis) return null
|
||||
const confirmResponse = await request(context, 'POST', `${base}/confirm?${query}`)
|
||||
const confirmed = context.apiSuccess(MODULE, '确认真实自动费用快照', confirmResponse, '', OPTIONS)
|
||||
const sendResponse = await request(context, 'POST', `${base}/send?${query}`)
|
||||
const sent = context.apiSuccess(MODULE, '发布真实自动费用来源', sendResponse, '', OPTIONS)
|
||||
return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING',
|
||||
direction: 'RECEIVABLE', currency: sent?.currency || confirmed?.currency || calculated.currency,
|
||||
amount: Number(sent?.totalAmount ?? confirmed?.totalAmount ?? calculated.totalAmount),
|
||||
businessDate: String(sent?.sentAt || confirmed?.confirmedAt || calculated.calculatedAt || '').slice(0, 10),
|
||||
billingBasis, chargeableQuantity: sent?.chargeableQuantity ?? calculated.chargeableQuantity,
|
||||
chargeUnit: sent?.chargeUnit || calculated.chargeUnit,
|
||||
direction: 'RECEIVABLE', currency: published.currency,
|
||||
amount: Number(published.amount), businessDate: String(published.businessDate || published.publishedAt || '').slice(0, 10),
|
||||
billingBasis, chargeableQuantity: published.chargeableQuantity,
|
||||
chargeUnit: published.chargeUnit,
|
||||
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
|
||||
}
|
||||
|
||||
@@ -328,7 +330,7 @@ export async function run(context) {
|
||||
}
|
||||
const fixtures = []
|
||||
for (const resource of measured.slice(0, 4)) {
|
||||
const fee = await calculateAutomaticFee(context, resource)
|
||||
const fee = await loadPublishedAutomaticFee(context, resource)
|
||||
if (fee) fixtures.push(fee)
|
||||
}
|
||||
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const moduleSource = fs.readFileSync(path.join(here, 'notifications.mjs'), 'utf8')
|
||||
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
|
||||
|
||||
test('消息中心模块由全量编排注入 context 且不直连环境', () => {
|
||||
assert.match(moduleSource, /export async function run\(context\)/)
|
||||
assert.doesNotMatch(moduleSource, /https?:\/\//)
|
||||
assert.doesNotMatch(moduleSource, /ADMIN_TEST_GATEWAY|process\.env|fetch\(/)
|
||||
assert.match(acceptance, /import\(new URL\(`\.\/admin-test-env-modules\/\$\{module\}\.mjs`/)
|
||||
})
|
||||
|
||||
test('正式 Admin 全量验收在 system 后注册消息中心', () => {
|
||||
assert.match(acceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'system',\s*'notifications'/s)
|
||||
})
|
||||
|
||||
test('覆盖模板生命周期、版本冲突、定时任务、幂等、查询和取消', () => {
|
||||
for (const token of [
|
||||
'创建消息模板草稿', '编辑 DRAFT 消息模板', '旧模板版本编辑被拒绝', '激活消息模板',
|
||||
'创建未来定时消息任务', '同幂等键重放定时消息任务', '按任务号和状态查询消息任务',
|
||||
'取消未来定时消息任务', '已取消任务禁止重试', '停用本轮消息模板',
|
||||
]) assert.match(moduleSource, new RegExp(token))
|
||||
assert.match(moduleSource, /recipientType:\s*'PLATFORM_ROLE'/)
|
||||
assert.match(moduleSource, /roleCode:\s*'PLATFORM_ADMIN'/)
|
||||
assert.match(moduleSource, /Date\.now\(\) \+ 24 \* 60 \* 60 \* 1000/)
|
||||
})
|
||||
|
||||
test('异常路径会取消定时任务并停用模板,不实际投递验收消息', () => {
|
||||
assert.match(moduleSource, /taskStatus === 'SCHEDULED'/)
|
||||
assert.match(moduleSource, /targetStatus:\s*'DISABLED'/)
|
||||
assert.match(moduleSource, /finalTemplateStatus/)
|
||||
assert.doesNotMatch(moduleSource, /scheduledAt:\s*null/)
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
const MODULE = 'notifications'
|
||||
const OPTIONS = { scenario: 'platform-notification-administration', deploymentBlocker: true }
|
||||
const TEMPLATE_BASE = '/api/platform/v1/notification-templates'
|
||||
const TASK_BASE = '/api/platform/v1/notification-tasks'
|
||||
const CREATE_PATH = '/api/platform/v1/notifications'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
function hasTrace(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) }
|
||||
function isConflict(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||
function query(base, values) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(values)) if (value !== '' && value != null) params.set(key, String(value))
|
||||
return `${base}?${params}`
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const suffix = context.marker.replace(/[^0-9A-Za-z]/g, '').slice(-20).toUpperCase()
|
||||
const templateCode = `ACPT_NOTIFICATION_${suffix}`.slice(0, 64)
|
||||
const category = 'ACCEPTANCE'
|
||||
const templateCreate = {
|
||||
templateCode,
|
||||
templateName: `全量验收消息模板-${suffix}`,
|
||||
category,
|
||||
titleTemplate: '全量验收通知 {{marker}}',
|
||||
bodyTemplate: '这是 {{marker}} 的消息中心全量验收定时任务。',
|
||||
variableWhitelist: ['marker'],
|
||||
}
|
||||
|
||||
const baselineResponse = await context.request('GET', query(TEMPLATE_BASE, { page: 1, size: 20, templateCode }))
|
||||
const baseline = context.apiSuccess(MODULE, '按 CODE 查询消息模板基线', baselineResponse, `templateCode=${templateCode}`, OPTIONS)
|
||||
context.assert(MODULE, '消息模板分页结构完整', baselineResponse,
|
||||
Array.isArray(baseline?.records) && Number(baseline?.page) === 1 && Number(baseline?.size) === 20,
|
||||
`records=${records(baseline).length}, total=${baseline?.total ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const invalidResponse = await context.request('POST', TEMPLATE_BASE, {
|
||||
...templateCreate,
|
||||
templateCode: `ACPT_INVALID_${suffix}`.slice(0, 64),
|
||||
variableWhitelist: [],
|
||||
})
|
||||
context.assert(MODULE, '模板占位变量未列入白名单时被拒绝且返回追踪信息', invalidResponse,
|
||||
invalidResponse.http === 200 && ['A0422', '422'].includes(codeOf(invalidResponse)) && hasTrace(invalidResponse),
|
||||
`HTTP=${invalidResponse.http}, code=${codeOf(invalidResponse)}, requestId=${invalidResponse.requestId || 'missing'}, traceId=${invalidResponse.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
const createResponse = await context.request('POST', TEMPLATE_BASE, templateCreate)
|
||||
let template = context.apiSuccess(MODULE, '创建消息模板草稿', createResponse, `templateCode=${templateCode}`, OPTIONS)
|
||||
const templateId = id(template?.id)
|
||||
context.assert(MODULE, '新建模板为 DRAFT 且仅支持站内信', createResponse,
|
||||
Boolean(templateId) && template?.status === 'DRAFT' && template?.channel === 'IN_APP' && Number(template?.version) === 1,
|
||||
`templateId=${templateId || 'missing'}, status=${template?.status || 'missing'}, channel=${template?.channel || 'missing'}`, OPTIONS)
|
||||
if (!templateId) return
|
||||
|
||||
let taskId = ''
|
||||
let taskStatus = ''
|
||||
let finalTemplateStatus = template?.status || 'UNKNOWN'
|
||||
try {
|
||||
const duplicateResponse = await context.request('POST', TEMPLATE_BASE, templateCreate)
|
||||
context.assert(MODULE, '重复模板 CODE 返回冲突并保留追踪信息', duplicateResponse,
|
||||
isConflict(duplicateResponse) && hasTrace(duplicateResponse),
|
||||
`code=${codeOf(duplicateResponse)}, requestId=${duplicateResponse.requestId || 'missing'}, traceId=${duplicateResponse.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
const updateResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}`, {
|
||||
templateName: `全量验收消息模板已编辑-${suffix}`,
|
||||
category,
|
||||
titleTemplate: '全量验收通知已编辑 {{marker}}',
|
||||
bodyTemplate: '这是 {{marker}} 的消息中心全量验收定时任务,模板已经编辑。',
|
||||
variableWhitelist: ['marker'],
|
||||
version: template.version,
|
||||
})
|
||||
template = context.apiSuccess(MODULE, '编辑 DRAFT 消息模板', updateResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '模板编辑结果及版本已持久化', updateResponse,
|
||||
template?.templateName === `全量验收消息模板已编辑-${suffix}` && Number(template?.version) === 2,
|
||||
`name=${template?.templateName || 'missing'}, version=${template?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const staleUpdate = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}`, {
|
||||
templateName: templateCreate.templateName,
|
||||
category,
|
||||
titleTemplate: templateCreate.titleTemplate,
|
||||
bodyTemplate: templateCreate.bodyTemplate,
|
||||
variableWhitelist: ['marker'],
|
||||
version: 1,
|
||||
})
|
||||
context.assert(MODULE, '旧模板版本编辑被拒绝且返回追踪信息', staleUpdate,
|
||||
isConflict(staleUpdate) && hasTrace(staleUpdate),
|
||||
`code=${codeOf(staleUpdate)}, requestId=${staleUpdate.requestId || 'missing'}, traceId=${staleUpdate.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
const activateResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}/status`, {
|
||||
targetStatus: 'ACTIVE', version: template?.version,
|
||||
})
|
||||
template = context.apiSuccess(MODULE, '激活消息模板', activateResponse, '', OPTIONS)
|
||||
finalTemplateStatus = template?.status || finalTemplateStatus
|
||||
context.assert(MODULE, '消息模板激活且版本递增', activateResponse,
|
||||
template?.status === 'ACTIVE' && Number(template?.version) === 3,
|
||||
`status=${template?.status || 'missing'}, version=${template?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const scheduledAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 19)
|
||||
const idempotencyKey = `${context.marker}-notification-scheduled`.slice(0, 128)
|
||||
const taskCreate = {
|
||||
tenantId: 0,
|
||||
templateCode,
|
||||
recipientType: 'PLATFORM_ROLE',
|
||||
roleCode: 'PLATFORM_ADMIN',
|
||||
variables: { marker: context.marker },
|
||||
scheduledAt,
|
||||
idempotencyKey,
|
||||
sourceEventId: `${context.marker}-notification-acceptance`.slice(0, 128),
|
||||
}
|
||||
const taskResponse = await context.request('POST', CREATE_PATH, taskCreate)
|
||||
let task = context.apiSuccess(MODULE, '创建未来定时消息任务', taskResponse, `scheduledAt=${scheduledAt}`, OPTIONS)
|
||||
taskId = id(task?.id); taskStatus = task?.status || ''
|
||||
context.assert(MODULE, '定时任务创建为 SCHEDULED 且解析平台角色接收人', taskResponse,
|
||||
Boolean(taskId) && task?.status === 'SCHEDULED' && Number(task?.recipientCount) > 0 && task?.duplicated === false,
|
||||
`taskId=${taskId || 'missing'}, status=${task?.status || 'missing'}, recipientCount=${task?.recipientCount ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const replayResponse = await context.request('POST', CREATE_PATH, taskCreate)
|
||||
const replay = context.apiSuccess(MODULE, '同幂等键重放定时消息任务', replayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '幂等重放返回同一任务且标记 duplicated', replayResponse,
|
||||
id(replay?.id) === taskId && replay?.duplicated === true,
|
||||
`original=${taskId || 'missing'}, replay=${id(replay?.id) || 'missing'}, duplicated=${replay?.duplicated ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const taskListResponse = await context.request('GET', query(TASK_BASE, { page: 1, size: 20, taskNo: task?.taskNo, category, status: 'SCHEDULED' }))
|
||||
const taskPage = context.apiSuccess(MODULE, '按任务号和状态查询消息任务', taskListResponse, `taskNo=${task?.taskNo || 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '任务列表命中本轮定时任务', taskListResponse,
|
||||
records(taskPage).some((row) => id(row?.id) === taskId && row?.status === 'SCHEDULED'),
|
||||
`taskId=${taskId || 'missing'}, records=${records(taskPage).length}`, OPTIONS)
|
||||
|
||||
if (taskId) {
|
||||
const cancelResponse = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/cancel`)
|
||||
task = context.apiSuccess(MODULE, '取消未来定时消息任务', cancelResponse, '', OPTIONS)
|
||||
taskStatus = task?.status || taskStatus
|
||||
context.assert(MODULE, '定时消息任务最终为 CANCELLED', cancelResponse,
|
||||
task?.status === 'CANCELLED', `status=${task?.status || 'missing'}`, OPTIONS)
|
||||
|
||||
const retryResponse = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/retry`)
|
||||
context.assert(MODULE, '已取消任务禁止重试且返回追踪信息', retryResponse,
|
||||
isConflict(retryResponse) && hasTrace(retryResponse),
|
||||
`code=${codeOf(retryResponse)}, requestId=${retryResponse.requestId || 'missing'}, traceId=${retryResponse.traceId || 'missing'}`, OPTIONS)
|
||||
}
|
||||
} finally {
|
||||
if (taskId && taskStatus === 'SCHEDULED') {
|
||||
const cleanupTask = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/cancel`)
|
||||
const cleanedTask = context.apiSuccess(MODULE, '异常路径取消未来定时消息任务', cleanupTask, `taskId=${taskId}`, OPTIONS)
|
||||
taskStatus = cleanedTask?.status || taskStatus
|
||||
}
|
||||
if (template?.status === 'ACTIVE') {
|
||||
const disableResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}/status`, {
|
||||
targetStatus: 'DISABLED', version: template.version,
|
||||
})
|
||||
template = context.apiSuccess(MODULE, '停用本轮消息模板', disableResponse, `templateId=${templateId}`, OPTIONS)
|
||||
finalTemplateStatus = template?.status || finalTemplateStatus
|
||||
context.assert(MODULE, '消息模板最终为 DISABLED', disableResponse,
|
||||
template?.status === 'DISABLED', `status=${template?.status || 'missing'}`, OPTIONS)
|
||||
}
|
||||
context.createdResources.notifications = {
|
||||
marker: context.marker, templateId, templateCode, finalTemplateStatus,
|
||||
taskId: taskId || null, taskStatus: taskStatus || null, retained: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export async function run(context) {
|
||||
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
|
||||
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
|
||||
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
|
||||
let allocatedContainerBookingId = id(changeConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
|
||||
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
|
||||
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
|
||||
@@ -70,22 +71,9 @@ export async function run(context) {
|
||||
context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed,
|
||||
Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS)
|
||||
if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
|
||||
const existingAllocation = changeConfirmed?.containerAllocations?.find?.(
|
||||
(item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))
|
||||
if (existingAllocation) {
|
||||
context.assert(MODULE, '确认订舱已自动关联本轮柜次', changed, true,
|
||||
`containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS)
|
||||
} else {
|
||||
const allocation = await request(context, 'POST', `${path}/allocations`, {
|
||||
requirementId: id(requirement.id), containerBookingId: id(containerFlow.containerBookingId), expectedVersion: version,
|
||||
}, { 'Idempotency-Key': `${context.marker}-ocean-container-allocation`.slice(0, 128) })
|
||||
const allocated = context.apiSuccess(MODULE, '放舱前分配本轮柜次', allocation, '', OPTIONS)
|
||||
version = Number(allocated?.oceanBooking?.version)
|
||||
context.assert(MODULE, '订舱柜次分配持久化且版本递增', allocation,
|
||||
allocated?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))
|
||||
&& Number.isInteger(version),
|
||||
`containerBookingId=${id(containerFlow.containerBookingId)}, version=${version}`, OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, '确认订舱自动生成并关联柜次', changed,
|
||||
Boolean(allocatedContainerBookingId) && Number.isInteger(version),
|
||||
`containerBookingId=${allocatedContainerBookingId || 'missing'}, version=${version}`, OPTIONS)
|
||||
}
|
||||
const released = await request(context, 'POST', `${path}/release`, {
|
||||
expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`,
|
||||
@@ -99,12 +87,12 @@ export async function run(context) {
|
||||
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
|
||||
context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final,
|
||||
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName)
|
||||
&& (!containerFlow || fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))),
|
||||
&& fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === allocatedContainerBookingId),
|
||||
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||
context.createdResources.oceanBooking = {
|
||||
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
|
||||
containerBookingId: containerFlow ? id(containerFlow.containerBookingId) : null,
|
||||
loadingPlanId: containerFlow ? id(containerFlow.loadingPlanId) : null,
|
||||
containerBookingId: allocatedContainerBookingId || null,
|
||||
loadingPlanId: null,
|
||||
finalStatus: fd?.oceanBooking?.status || null,
|
||||
}
|
||||
const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`)
|
||||
|
||||
@@ -5,13 +5,19 @@ const CONTAINERS = '/api/platform/v1/containers'
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3ContainerLoading
|
||||
const loadingRequested = context.options?.modules?.includes('phase3-loading') === true
|
||||
const fixture = loadingRequested
|
||||
? context.createdResources?.phase3Loading
|
||||
: context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
|
||||
const containerId = id(fixture?.containerBookingId)
|
||||
const planId = id(fixture?.loadingPlanId)
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
if (!containerId || !planId || !warehouseId || fixture?.marker !== context.marker) {
|
||||
const markerMatches = fixture?.marker === context.marker
|
||||
|| fixture?.marker === `${context.marker}-CAB`
|
||||
|| fixture?.sourceMarker === `${context.marker}-CAB`
|
||||
if (!containerId || !planId || !warehouseId || !markerMatches) {
|
||||
context.addStep(MODULE, '复用智能装柜产生的货柜 overview fixture', null, false,
|
||||
`containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
||||
`source=${loadingRequested ? 'phase3Loading-required' : 'available-container-fixture'}, containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,12 +37,15 @@ export async function run(context) {
|
||||
`events=${timeline.length}`, OPTIONS)
|
||||
|
||||
if (overview?.pda) {
|
||||
const expectsPhotos = fixture === context.createdResources?.phase3Loading
|
||||
&& Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0
|
||||
context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse,
|
||||
['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount']
|
||||
.every((field) => overview.pda[field] !== undefined)
|
||||
&& Array.isArray(overview.pda.photoCounts)
|
||||
&& Array.isArray(overview.pda.photos)
|
||||
&& Array.isArray(overview.pda.exceptionReasons),
|
||||
&& Array.isArray(overview.pda.exceptionReasons)
|
||||
&& (!expectsPhotos || overview.pda.photos.length > 0),
|
||||
`taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS)
|
||||
for (const [index, photo] of overview.pda.photos.entries()) {
|
||||
context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse,
|
||||
|
||||
@@ -271,8 +271,11 @@ export async function run(context) {
|
||||
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
|
||||
const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId)
|
||||
const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId
|
||||
&& id(inventory?.loadingPlanId) === loadingPlanId
|
||||
&& id(inventory?.containerBookingId) === containerBookingId
|
||||
&& Number(inventory?.taskVersion) === Number(claimed?.version)
|
||||
&& inventory?.containerNo === containerNo && id(inventory?.warehouseId) === warehouseId
|
||||
&& id(inventory?.cabinetNo) && inventory?.currentContainerNo === containerNo
|
||||
&& id(inventory?.warehouseId) === warehouseId
|
||||
&& Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string'
|
||||
&& inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1
|
||||
&& (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId)
|
||||
@@ -299,7 +302,7 @@ export async function run(context) {
|
||||
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
|
||||
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
|
||||
const batchPayload = {
|
||||
warehouseId, containerNo,
|
||||
warehouseId, loadingPlanId, containerBookingId,
|
||||
expectedVersion: Number(claimed?.version),
|
||||
inventorySnapshotToken: inventory.snapshotToken,
|
||||
items: [{
|
||||
@@ -477,36 +480,50 @@ export async function run(context) {
|
||||
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
||||
{ 'Idempotency-Key': batchKey })
|
||||
const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS)
|
||||
const directPdaCompletion = pickedCanonical?.taskStatus === 'COMPLETED'
|
||||
&& pickedCanonical?.workflowStatus === 'COMPLETED'
|
||||
context.assert(MODULE, 'PDA 原子批次成功并推进任务终态', canonicalPick,
|
||||
directPdaCompletion && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
|
||||
const awaitingReview = pickedCanonical?.taskStatus === 'LOADING'
|
||||
&& pickedCanonical?.workflowStatus === 'AWAITING_REVIEW'
|
||||
context.assert(MODULE, 'PDA 原子批次完成拣货并等待人工确认', canonicalPick,
|
||||
awaitingReview && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
|
||||
`taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
||||
{ 'Idempotency-Key': batchKey })
|
||||
const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200'
|
||||
&& id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId
|
||||
&& canonicalBatchReplay.body?.data?.taskStatus === 'COMPLETED'
|
||||
&& canonicalBatchReplay.body?.data?.taskStatus === 'LOADING'
|
||||
&& canonicalBatchReplay.body?.data?.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& Number(canonicalBatchReplay.body?.data?.version) === Number(pickedCanonical?.version)
|
||||
context.assert(MODULE, 'PDA 原子批次同参同键重放返回同一终态', canonicalBatchReplay,
|
||||
context.assert(MODULE, 'PDA 原子批次同参同键重放保持待人工确认', canonicalBatchReplay,
|
||||
atomicBatchReplayVerified,
|
||||
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const completeResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
|
||||
expectedTaskVersion: Number(pickedCanonical?.version),
|
||||
operationId: `${context.marker}-bridge-complete`.slice(0, 128), remark: context.marker,
|
||||
})
|
||||
const completedCanonical = context.apiSuccess(MODULE, '人工确认柜次 PDA 装柜完成', completeResponse, '', OPTIONS)
|
||||
const directPdaCompletion = completedCanonical?.taskStatus === 'COMPLETED'
|
||||
&& completedCanonical?.workflowStatus === 'COMPLETED'
|
||||
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse,
|
||||
directPdaCompletion && completedCanonical?.items?.every?.((item) => item.itemStatus === 'PICKED'),
|
||||
`taskStatus=${completedCanonical?.taskStatus || 'missing'}, workflow=${completedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
|
||||
const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW', completionOperationsResponse,
|
||||
context.assert(MODULE, '人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计', completionOperationsResponse,
|
||||
Array.isArray(completionOperations)
|
||||
&& completionOperations.some((item) => item.actionType === 'CLAIM'
|
||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||
&& completionOperations.some((item) => item.actionType === 'BATCH'
|
||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||
&& completionOperations.some((item) => item.actionType === 'COMPLETE_LOADING'
|
||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||
&& !completionOperations.some((item) => item.actionType === 'REVIEW'),
|
||||
`actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||
|
||||
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '首笔真实拣货触发装柜状态并由单明细批次直接完成', pickedPlanResponse,
|
||||
context.assert(MODULE, '人工确认后柜次装柜计划完成', pickedPlanResponse,
|
||||
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
|
||||
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -547,11 +564,10 @@ export async function run(context) {
|
||||
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!Number.isInteger(loadingVersion)) return
|
||||
|
||||
// The deployed settlement contract completes a one-item task during pick. A
|
||||
// review call is therefore a negative compatibility assertion, never a second
|
||||
// positive action that can poison the marker fixture with A0409.
|
||||
// COMPLETE_LOADING is the only positive completion action; REVIEW is a
|
||||
// negative compatibility assertion after the task is already complete.
|
||||
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
|
||||
expectedTaskVersion: Number(pickedCanonical?.version),
|
||||
expectedTaskVersion: Number(completedCanonical?.version),
|
||||
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
|
||||
})
|
||||
context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview,
|
||||
@@ -560,55 +576,8 @@ export async function run(context) {
|
||||
: canonicalReview.http === 200 && codeOf(canonicalReview) === '200',
|
||||
`HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS)
|
||||
|
||||
const forgedPickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: 'forged-client-count' },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-forged-start`.slice(0, 128) })
|
||||
context.addStep(MODULE, '入库级 PDA 汇总拒绝客户端伪造数量', forgedPickingStart, failure(forgedPickingStart),
|
||||
`HTTP=${forgedPickingStart.http}, code=${codeOf(forgedPickingStart)}`, OPTIONS)
|
||||
|
||||
const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) })
|
||||
const pickingStarted = directPdaCompletion
|
||||
? null
|
||||
: context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS)
|
||||
context.addStep(MODULE, directPdaCompletion
|
||||
? '柜次 PDA 已完成后拒绝旧入库级启动入口' : '开始本轮 PDA 拣货任务', pickingStart,
|
||||
directPdaCompletion
|
||||
? pickingStart.http === 200 && codeOf(pickingStart) === 'A0409'
|
||||
: Boolean(pickingStarted),
|
||||
`HTTP=${pickingStart.http}, code=${codeOf(pickingStart)}, status=${pickingStarted?.status || 'n/a'}`, OPTIONS)
|
||||
if (!directPdaCompletion) {
|
||||
context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart,
|
||||
pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0
|
||||
&& Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 1,
|
||||
`status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-mismatch`.slice(0, 128) })
|
||||
context.addStep(MODULE, 'PDA 拣货数量不一致时拒绝完成', pickingMismatch, failure(pickingMismatch),
|
||||
`HTTP=${pickingMismatch.http}, code=${codeOf(pickingMismatch)}`, OPTIONS)
|
||||
|
||||
const pickingComplete = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-picking-complete`.slice(0, 128) })
|
||||
const pickingCompleted = directPdaCompletion
|
||||
? null
|
||||
: context.apiSuccess(MODULE, '完成本轮 PDA 拣货复核', pickingComplete, '', OPTIONS)
|
||||
context.addStep(MODULE, directPdaCompletion
|
||||
? '柜次 PDA 已完成后拒绝旧入库级完成入口' : '完成本轮 PDA 拣货复核', pickingComplete,
|
||||
directPdaCompletion
|
||||
? pickingComplete.http === 200 && codeOf(pickingComplete) === 'A0409'
|
||||
: Boolean(pickingCompleted),
|
||||
`HTTP=${pickingComplete.http}, code=${codeOf(pickingComplete)}, status=${pickingCompleted?.status || 'n/a'}`, OPTIONS)
|
||||
if (!directPdaCompletion) {
|
||||
context.assert(MODULE, 'PDA 拣货任务 COMPLETED 且数量一致', pickingComplete,
|
||||
pickingCompleted?.status === 'COMPLETED' && Number(pickingCompleted?.version) === 1
|
||||
&& Number(pickingCompleted?.expectedCount) === 1 && Number(pickingCompleted?.pickedCount) === 1,
|
||||
`status=${pickingCompleted?.status || 'missing'}, version=${pickingCompleted?.version ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true,
|
||||
'本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS)
|
||||
|
||||
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
||||
const evidenceItems = []
|
||||
@@ -814,8 +783,15 @@ export async function run(context) {
|
||||
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
|
||||
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const beforeHardGateResponse = await context.request('GET', detailPath)
|
||||
const beforeHardGate = context.apiSuccess(MODULE, '读取 LOADED 硬门禁调用前即时版本', beforeHardGateResponse, '', OPTIONS)
|
||||
const beforeHardGateVersion = Number(beforeHardGate?.summary?.version)
|
||||
context.assert(MODULE, 'LOADED 硬门禁即时基线状态和版本有效', beforeHardGateResponse,
|
||||
beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion),
|
||||
`status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!Number.isInteger(beforeHardGateVersion)) return
|
||||
const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: loadingVersion, remark: `${context.marker}-before-downstream` },
|
||||
{ expectedVersion: beforeHardGateVersion, remark: `${context.marker}-before-downstream` },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
|
||||
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
|
||||
failure(blockedBeforeDownstream),
|
||||
@@ -824,7 +800,7 @@ export async function run(context) {
|
||||
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
|
||||
blockedDetail?.summary?.processStatus === 'LOADING'
|
||||
&& Number(blockedDetail?.summary?.version) === loadingVersion,
|
||||
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion,
|
||||
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
context.createdResources.phase3Loading = {
|
||||
@@ -835,7 +811,7 @@ export async function run(context) {
|
||||
postLoadedFileId,
|
||||
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
|
||||
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
|
||||
atomicBatchVerified: directPdaCompletion && atomicBatchReplayVerified,
|
||||
atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified,
|
||||
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
|
||||
inventoryMutationVerified, inventoryMutationCleanup,
|
||||
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED',
|
||||
|
||||
@@ -5,7 +5,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'
|
||||
import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
function source(name) {
|
||||
return readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||
@@ -115,6 +115,11 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
|
||||
'const canonicalBatchReplay = await request',
|
||||
])
|
||||
assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/)
|
||||
assert.match(loading, /warehouseId, loadingPlanId, containerBookingId,/)
|
||||
assert.match(loading, /inventory\?\.currentContainerNo === containerNo/)
|
||||
const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch ='))
|
||||
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
|
||||
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
|
||||
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
|
||||
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
|
||||
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
|
||||
@@ -229,9 +234,10 @@ test('phase3 loading proves stale inventory zero-write for all items and complet
|
||||
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
|
||||
'库存变化后刷新 PDA 实时库存快照',
|
||||
'原子提交柜次 PDA 整批拣货',
|
||||
'PDA 原子批次同参同键重放返回同一终态',
|
||||
'PDA 原子批次同参同键重放保持待人工确认',
|
||||
'人工确认柜次 PDA 装柜完成',
|
||||
'回查原子 Batch 完成审计',
|
||||
'合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW',
|
||||
'人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -285,6 +291,23 @@ test('default acceptance order verifies loading evidence before finalizing LOADE
|
||||
])
|
||||
})
|
||||
|
||||
test('container overview always follows phase3 loading in default and custom module selections', () => {
|
||||
const reordered = normalizeModuleOrder(['phase3-container-overview', 'orders', 'phase3-loading', 'finance'])
|
||||
assert.deepEqual(reordered, ['orders', 'phase3-loading', 'phase3-container-overview', 'finance'])
|
||||
assert.deepEqual(parseArguments(['--modules', 'phase3-container-overview,phase3-loading']).modules,
|
||||
['phase3-loading', 'phase3-container-overview'])
|
||||
|
||||
const entry = source('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
|
||||
assert.match(entry, /modules: normalizeModuleOrder\(/)
|
||||
})
|
||||
|
||||
test('overview does not fall back to a container fixture when loading evidence was requested', () => {
|
||||
const overview = source('./phase3-container-overview.mjs')
|
||||
assert.match(overview, /loadingRequested[\s\S]*\? context\.createdResources\?\.phase3Loading/)
|
||||
assert.match(overview, /source=\$\{loadingRequested \? 'phase3Loading-required'/)
|
||||
})
|
||||
|
||||
test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', async () => {
|
||||
const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
|
||||
const entry = source('../admin-test-env-acceptance.mjs')
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8'))
|
||||
.replaceAll('\r\n', '\n')
|
||||
|
||||
test('AUTO detects database migrations without expanding them to all services', () => {
|
||||
assert.match(pipeline, /def databaseMigrationPaths = \['deploy\/sql\/'\]/)
|
||||
assert.match(pipeline, /!databaseMigration && commonPaths\.any/)
|
||||
assert.match(pipeline, /env\.DATABASE_CHANGE_DETECTED = databaseChanged \? 'true' : 'false'/)
|
||||
})
|
||||
|
||||
test('database-only changes are reported as a separate migration action', () => {
|
||||
assert.match(pipeline, /仅数据库迁移变更 \| 需单独审核执行/)
|
||||
assert.match(pipeline, /统一 SQL 须独立审核并执行/)
|
||||
})
|
||||
Reference in New Issue
Block a user