优化
This commit is contained in:
@@ -54,10 +54,20 @@ export async function run(context) {
|
||||
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
|
||||
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
||||
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
|
||||
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 四期船节点只读冒烟 | `phase4-carrier-nodes.mjs` | `phase4-carrier-nodes-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
|
||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||
|
||||
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;船节点禁止手工创建任务。所有模块继续禁止调用海外出库、派送、PDA 或内部接口。开发环境定向执行示例:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway http://8.163.49.32:9000 --overseas-gateway http://8.163.49.32:9300 --modules phase4-overseas-warehouse,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
|
||||
```
|
||||
|
||||
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
|
||||
|
||||
## WAIT_SPLIT 到 LOADED 冻结链
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
|
||||
|
||||
function response(data) { return { http: 200, body: { code: '200', data } } }
|
||||
function contextFor(routes) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
request: async (method, requestPath) => {
|
||||
calls.push({ method, path: requestPath })
|
||||
assert.equal(method, 'GET')
|
||||
assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`)
|
||||
return response(routes[requestPath])
|
||||
},
|
||||
apiSuccess: (_module, _name, result) => {
|
||||
assert.equal(result.http, 200)
|
||||
assert.equal(result.body.code, '200')
|
||||
return result.body.data
|
||||
},
|
||||
assert: (_module, name, _result, condition, evidence) => {
|
||||
assert.ok(condition, `${name}: ${evidence}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001',
|
||||
oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO',
|
||||
trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED',
|
||||
latestStandardNode: 'PICKUP_READY', latestNodeObservedAt: '2026-08-20T12:00:00',
|
||||
lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00',
|
||||
expiresAt: null, stoppedAt: '2026-08-20T12:01:00', stopReason: '目的港可提柜后停止',
|
||||
}
|
||||
const node = {
|
||||
id: '40001', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY',
|
||||
nodeSequence: 60, observedAt: '2026-08-20T12:00:00', estimatedAt: null,
|
||||
actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles',
|
||||
sourceReference: null, createdAt: '2026-08-20T12:00:01',
|
||||
}
|
||||
const history = {
|
||||
id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5,
|
||||
reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00',
|
||||
}
|
||||
const carriers = [{
|
||||
carrierCode: 'COSCO', carrierName: 'COSCO', officialUrl: 'https://example.invalid',
|
||||
accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false,
|
||||
}]
|
||||
|
||||
test('readonly module statically forbids writes and internal execution endpoints', () => {
|
||||
assert.match(source, /context\.request\('GET'/)
|
||||
assert.doesNotMatch(source, /\bfetch\s*\(/)
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`))
|
||||
}
|
||||
for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) {
|
||||
assert.doesNotMatch(source, new RegExp(endpoint.replace('/', '\\/'), 'i'))
|
||||
}
|
||||
})
|
||||
|
||||
test('canonical task and node sets are locked to the phase-4 read contract', () => {
|
||||
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED'])
|
||||
assert.deepEqual(STANDARD_NODES, [
|
||||
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT',
|
||||
'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY',
|
||||
])
|
||||
})
|
||||
|
||||
test('empty page validates read-only pagination without detail calls', async () => {
|
||||
const context = contextFor({
|
||||
'/api/platform/v1/carrier-schedule-queries/carriers': carriers,
|
||||
'/api/platform/v1/carrier-schedule-queries?page=1&size=20': { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
await run(context)
|
||||
assert.deepEqual(context.calls, [
|
||||
{ method: 'GET', path: '/api/platform/v1/carrier-schedule-queries/carriers' },
|
||||
{ method: 'GET', path: '/api/platform/v1/carrier-schedule-queries?page=1&size=20' },
|
||||
])
|
||||
})
|
||||
|
||||
test('existing task reads page, detail and history with nullable node times', async () => {
|
||||
const base = '/api/platform/v1/carrier-schedule-queries'
|
||||
const context = contextFor({
|
||||
[`${base}/carriers`]: carriers,
|
||||
[`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 },
|
||||
[`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], histories: [history] },
|
||||
[`${base}/${task.id}/history`]: [history],
|
||||
})
|
||||
await run(context)
|
||||
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET'])
|
||||
assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
const MODULE = 'phase4-carrier-nodes'
|
||||
const OPTIONS = { scenario: 'phase4-carrier-nodes-lifecycle', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
||||
|
||||
export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED'])
|
||||
export const STANDARD_NODES = Object.freeze([
|
||||
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT',
|
||||
'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY',
|
||||
])
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function number(value) { return Number.isFinite(Number(value)) }
|
||||
function nullableText(value) { return value == null || typeof value === 'string' }
|
||||
function nullableId(value) { return value == null || Boolean(id(value)) }
|
||||
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) }
|
||||
|
||||
function validTask(task) {
|
||||
return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId))
|
||||
&& QUERY_STATUSES.includes(task?.status)
|
||||
&& nullableId(task?.containerBookingId)
|
||||
&& nullableText(task?.latestStandardNode)
|
||||
&& (task?.latestStandardNode == null || STANDARD_NODES.includes(task.latestStandardNode))
|
||||
&& ['latestNodeObservedAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
|
||||
.every((field) => nullableIso(task?.[field]))
|
||||
&& nullableText(task?.stopReason)
|
||||
}
|
||||
|
||||
function validNode(node) {
|
||||
return Boolean(id(node?.id) && node?.providerCode && node?.providerNodeCode)
|
||||
&& STANDARD_NODES.includes(node?.standardNode)
|
||||
&& number(node?.nodeSequence) && Number(node.nodeSequence) > 0
|
||||
&& nullableIso(node?.observedAt) && node?.observedAt != null
|
||||
&& nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt)
|
||||
&& nullableText(node?.locationCode) && nullableText(node?.locationName)
|
||||
&& nullableText(node?.sourceReference) && nullableIso(node?.createdAt)
|
||||
}
|
||||
|
||||
function validHistory(item) {
|
||||
return Boolean(id(item?.id) && item?.action)
|
||||
&& (item?.fromVersion == null || number(item.fromVersion))
|
||||
&& (item?.toVersion == null || number(item.toVersion))
|
||||
&& nullableText(item?.reason) && nullableId(item?.operatorId)
|
||||
&& nullableText(item?.operatorName) && nullableIso(item?.createdAt)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const carriersResponse = await context.request('GET', `${BASE}/carriers`)
|
||||
const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '船司辅助信息保持公开只读描述结构', carriersResponse,
|
||||
Array.isArray(carriers) && carriers.every((item) => item?.carrierCode && item?.carrierName
|
||||
&& nullableText(item?.officialUrl) && nullableText(item?.accessMode)
|
||||
&& typeof item?.automated === 'boolean' && typeof item?.coverageGap === 'boolean'),
|
||||
`count=${Array.isArray(carriers) ? carriers.length : 'missing'}`, OPTIONS)
|
||||
|
||||
const pageResponse = await context.request('GET', `${BASE}?page=1&size=20`)
|
||||
const page = context.apiSuccess(MODULE, '查询四期船节点任务分页', pageResponse, '', OPTIONS)
|
||||
const records = Array.isArray(page?.records) ? page.records : []
|
||||
context.assert(MODULE, '船节点分页结构稳定', pageResponse,
|
||||
Array.isArray(page?.records) && ['page', 'size', 'total', 'pages'].every((field) => number(page?.[field]))
|
||||
&& records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)),
|
||||
`records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS)
|
||||
|
||||
if (records.length === 0) {
|
||||
context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse,
|
||||
Number(page?.total) === 0 && Number(page?.pages) === 0,
|
||||
`records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const queryId = id(records[0]?.query?.id)
|
||||
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS)
|
||||
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
|
||||
const histories = Array.isArray(detail?.histories) ? detail.histories : []
|
||||
context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse,
|
||||
validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean'
|
||||
&& Array.isArray(detail?.nodes) && nodes.every(validNode)
|
||||
&& Array.isArray(detail?.histories) && histories.every(validHistory),
|
||||
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, histories=${histories.length}`, OPTIONS)
|
||||
|
||||
const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`)
|
||||
const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, '独立历史端点返回可审计操作者与nullable字段', historyResponse,
|
||||
Array.isArray(history) && history.every(validHistory),
|
||||
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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'
|
||||
import { run } from './phase4-overseas-business.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
|
||||
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
|
||||
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
|
||||
|
||||
function fixture({ ownedPurchase = false, historicalPurchase = false } = {}) {
|
||||
const calls = []; const steps = []; const gaps = []
|
||||
const state = {
|
||||
truck: null,
|
||||
looseboard: null,
|
||||
purchase: { id: '30001', orderNo: 'OP1', customerId: '10001', purchaseWarehouseId: '20001', purchaseCategory: '家居', productName: '置物架', purchaseQuantity: 2, inboundQuantity: 0, inventoryQuantity: 0, status: 'PENDING_REVIEW', version: 0 },
|
||||
}
|
||||
const marker = 'HTTP-20260821-001'
|
||||
const context = {
|
||||
marker, calls, steps, gaps, context: {},
|
||||
createdResources: ownedPurchase ? { overseasProcurement: { marker, orderNo: 'OP1', customerId: '10001' } } : {},
|
||||
async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, path: requestPath, body }); return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) },
|
||||
async requestOverseas(method, requestPath, body) {
|
||||
calls.push({ channel: 'overseas', method, path: requestPath, body })
|
||||
if (requestPath.includes('/customer-options')) return response([{ customerId: '10001', customerName: '客户甲' }])
|
||||
if (requestPath.includes('/warehouse-options')) return response([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }])
|
||||
if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page(ownedPurchase || historicalPurchase ? [state.purchase] : []))
|
||||
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response({ summary: state.purchase, remark: marker })
|
||||
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { state.purchase = { ...state.purchase, ...body, version: state.purchase.version + 1 }; return response({ summary: state.purchase }) }
|
||||
if (requestPath.includes('/us-truck-orders?') && method === 'POST') { state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }; return response({ summary: state.truck }) }
|
||||
if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 }; return response({ summary: state.truck }) }
|
||||
if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response({ summary: state.truck })
|
||||
if (requestPath.includes('/us-truck-orders/TK1?') && method === 'PATCH') { if (Number(body.version) !== state.truck.version) return response(null, 'A0409'); state.truck = { ...state.truck, ...body, version: state.truck.version + 1 }; return response({ summary: state.truck }) }
|
||||
if (requestPath.includes('/local-looseboard-orders?') && method === 'POST') { state.looseboard = { id: '50001', orderNo: 'LB1', customerId: '10001', status: 'PENDING_ACCEPTANCE', version: 0, ...body }; return response({ summary: state.looseboard }) }
|
||||
if (requestPath.includes('/local-looseboard-orders/LB1/status?') && method === 'PATCH') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409'); state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 }; return response({ summary: state.looseboard }) }
|
||||
if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response({ summary: state.looseboard })
|
||||
if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409'); state.looseboard = { ...state.looseboard, ...body, version: state.looseboard.version + 1 }; return response({ summary: state.looseboard }) }
|
||||
throw new Error(`unexpected ${method} ${requestPath}`)
|
||||
},
|
||||
apiSuccess(module, name, result, evidence, options) { const passed = result.http === 200 && String(result.body?.code) === '200'; steps.push({ module, name, passed, evidence, options }); return passed ? result.body.data : null },
|
||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, passed: Boolean(passed), result, evidence, options }) },
|
||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, passed: Boolean(passed), result, evidence, options }) },
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
test('module is context injected and excludes forbidden business surfaces', () => {
|
||||
assert.match(source, /export async function run\(context\)/)
|
||||
assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/)
|
||||
for (const forbidden of ['/merchant/', '/review', '/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) assert.equal(source.includes(forbidden), false, forbidden)
|
||||
assert.doesNotMatch(source, /requestOverseas\('DELETE'/)
|
||||
})
|
||||
|
||||
test('marker truck and looseboard cover create detail edit stale conflict and finally cancel', async () => {
|
||||
const context = fixture()
|
||||
await run(context)
|
||||
assert.equal(context.steps.every((step) => step.passed), true, context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
||||
assert.deepEqual(context.calls.filter((call) => call.method === 'POST').map((call) => call.path), [
|
||||
'/api/platform/v1/last-leg/us-truck-orders?customerId=10001',
|
||||
'/api/platform/v1/last-leg/local-looseboard-orders?customerId=10001',
|
||||
])
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/us-truck-orders/')).length, 3)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT' && call.path.includes('/local-looseboard-orders/')).length, 2)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PATCH' && call.path.includes('/local-looseboard-orders/')).length, 1)
|
||||
const statusCalls = context.calls.filter((call) => call.path.includes('/status?'))
|
||||
assert.deepEqual(statusCalls.map((call) => [call.method, call.path, call.body.status]), [
|
||||
['PATCH', '/api/platform/v1/last-leg/us-truck-orders/TK1/status?customerId=10001', 'CANCELLED'],
|
||||
['PATCH', '/api/platform/v1/last-leg/local-looseboard-orders/LB1/status?customerId=10001', 'CANCELLED'],
|
||||
])
|
||||
assert.ok(statusCalls.every((call) => Number(call.body.version) === 1))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.marker, context.marker)
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.marker, context.marker)
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.finalStatus, 'CANCELLED')
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.finalStatus, 'CANCELLED')
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.retained, false)
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.looseboard.retained, false)
|
||||
assert.equal(context.gaps.filter((gap) => gap[4]?.includes('retained-marker-test-data')).length, 0)
|
||||
assert.ok(context.gaps.every((gap) => !/requestId=missing|traceId=missing/.test(gap[3])))
|
||||
})
|
||||
|
||||
test('historical procurement data is never patched without marker ownership', async () => {
|
||||
const context = fixture({ historicalPurchase: true })
|
||||
await run(context)
|
||||
assert.equal(context.calls.some((call) => call.method === 'PATCH' && call.path.includes('/overseas-procurements/')), false)
|
||||
assert.equal(context.gaps.some((gap) => gap[4] === 'missing-marker-owned-procurement'), true)
|
||||
})
|
||||
|
||||
test('marker-owned procurement may be patched but never created, reviewed or shipped', async () => {
|
||||
const context = fixture({ ownedPurchase: true })
|
||||
await run(context)
|
||||
const purchaseWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET')
|
||||
assert.deepEqual(purchaseWrites.map((call) => call.method), ['PATCH'])
|
||||
assert.match(purchaseWrites[0].body.productSpecification, new RegExp(context.marker))
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.purchase.marker, context.marker)
|
||||
})
|
||||
|
||||
test('contract locks real candidates, idempotency, version conflicts and trace evidence', () => {
|
||||
for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE', '/overseas-procurements/customer-options?limit=200',
|
||||
'/overseas-procurements/warehouse-options', 'idempotencyKey', 'phase4-truck', 'phase4-looseboard',
|
||||
'旧 version', '/status', "status: 'CANCELLED'", 'finally', 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token)
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const SCENARIO = 'phase4-overseas-business-write-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/last-leg'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
function query(path, params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) if (value !== '' && value != null) search.set(key, String(value)); return `${path}?${search}` }
|
||||
function summary(data) { return data?.summary && typeof data.summary === 'object' ? data.summary : data }
|
||||
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response).endsWith('409')) }
|
||||
function longId(value) { return /^[1-9]\d*$/.test(id(value)) }
|
||||
function pageValid(page) { return Array.isArray(page?.records) && Number(page?.page) >= 1 && Number(page?.size) >= 1 && Number(page?.total) >= page.records.length && Number(page?.pages) >= 0 }
|
||||
function coverageGap(context, name, reason, category = 'prerequisite') { context.addCoverageGap(MODULE, SCENARIO, name, reason, category) }
|
||||
function ownedRegistry(context) { context.createdResources.phase4OverseasBusiness ??= { marker: context.marker }; return context.createdResources.phase4OverseasBusiness }
|
||||
|
||||
async function domesticGet(context, name, path) {
|
||||
const response = await context.request('GET', path)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
async function overseas(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
function assertPage(context, name, result) {
|
||||
context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data),
|
||||
`records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
async function prerequisites(context) {
|
||||
const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
|
||||
assertPage(context, 'ACTIVE 客户候选', tenants)
|
||||
const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE')
|
||||
if (!activeCustomers.length) {
|
||||
coverageGap(context, '海外业务写验收 ACTIVE 客户前置', `IAM 未返回真实 ACTIVE 客户;${trace(tenants.response)}`, 'missing-active-customer')
|
||||
return null
|
||||
}
|
||||
const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`)
|
||||
const warehouseOptions = await overseas(context, 'GET', '查询 ACTIVE 采购仓候选', `${BASE}/overseas-procurements/warehouse-options`)
|
||||
context.assert(MODULE, '采购客户候选结构正确', customerOptions.response,
|
||||
Array.isArray(customerOptions.data) && customerOptions.data.every((item) => longId(item?.customerId) && String(item?.customerName || '').trim()),
|
||||
`options=${customerOptions.data?.length ?? 'missing'}; ${trace(customerOptions.response)}`, OPTIONS)
|
||||
context.assert(MODULE, '采购仓候选结构正确', warehouseOptions.response,
|
||||
Array.isArray(warehouseOptions.data) && warehouseOptions.data.every((item) => longId(item?.warehouseId) && String(item?.warehouseName || '').trim()),
|
||||
`options=${warehouseOptions.data?.length ?? 'missing'}; ${trace(warehouseOptions.response)}`, OPTIONS)
|
||||
const activeIds = new Set(activeCustomers.map((item) => id(item.id)))
|
||||
const procurementCustomer = Array.isArray(customerOptions.data)
|
||||
? customerOptions.data.find((item) => activeIds.has(id(item?.customerId))) : null
|
||||
const warehouse = Array.isArray(warehouseOptions.data) ? warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null
|
||||
return { activeCustomer: activeCustomers[0], procurementCustomer, warehouse, customerOptions, warehouseOptions }
|
||||
}
|
||||
|
||||
function markerOwnedPurchase(context) {
|
||||
const candidates = [context.createdResources?.phase4OverseasBusiness?.purchase, context.createdResources?.overseasProcurement]
|
||||
return candidates.find((item) => item?.marker === context.marker && id(item?.orderNo) && longId(item?.customerId)) || null
|
||||
}
|
||||
|
||||
async function procurementFlow(context, prerequisite) {
|
||||
if (!prerequisite.procurementCustomer || !prerequisite.warehouse) {
|
||||
coverageGap(context, '采购真实候选与列表前置', `ACTIVE 客户与采购客户候选无交集或无 ACTIVE 采购仓;customerOptions=${prerequisite.customerOptions.data?.length ?? 0}, warehouseOptions=${prerequisite.warehouseOptions.data?.length ?? 0};customer=${trace(prerequisite.customerOptions.response)};warehouse=${trace(prerequisite.warehouseOptions.response)}`, 'missing-procurement-candidate')
|
||||
return
|
||||
}
|
||||
const customerId = id(prerequisite.procurementCustomer.customerId)
|
||||
const warehouseId = id(prerequisite.warehouse.warehouseId)
|
||||
const list = await overseas(context, 'GET', '按真实客户与采购仓查询采购订单', query(`${BASE}/overseas-procurements`, { page: 1, size: 20, customerId, purchaseWarehouseId: warehouseId }))
|
||||
assertPage(context, '海外采购订单', list)
|
||||
const owned = markerOwnedPurchase(context)
|
||||
const row = records(list.data).find((item) => owned && item?.orderNo === owned.orderNo && id(item?.customerId) === id(owned.customerId))
|
||||
if (!row) {
|
||||
coverageGap(context, '采购 PATCH 本轮资源', `平台无采购 create,列表中没有 marker=${context.marker} 的本轮自有采购单;禁止修改历史客户数据;${trace(list.response)}`, 'missing-marker-owned-procurement')
|
||||
return
|
||||
}
|
||||
const detailPath = query(`${BASE}/overseas-procurements/${encodeURIComponent(row.orderNo)}`, { customerId: id(row.customerId) })
|
||||
const detail = await overseas(context, 'GET', '查询本轮自有采购订单详情', detailPath)
|
||||
const current = summary(detail.data)
|
||||
const update = await overseas(context, 'PATCH', '编辑本轮自有采购订单', detailPath,
|
||||
{ version: Number(current?.version), productSpecification: `${context.marker}-procurement`, remark: context.marker })
|
||||
const updated = summary(update.data)
|
||||
context.assert(MODULE, '本轮采购编辑回显 marker 且版本递增', update.response,
|
||||
updated?.productSpecification === `${context.marker}-procurement` && Number(updated?.version) === Number(current?.version) + 1,
|
||||
`orderNo=${id(updated?.orderNo) || 'missing'}, version=${updated?.version ?? 'missing'}; ${trace(update.response)}`, OPTIONS)
|
||||
ownedRegistry(context).purchase = { marker: context.marker, orderNo: id(updated?.orderNo), customerId: id(updated?.customerId), version: Number(updated?.version), retained: true }
|
||||
}
|
||||
|
||||
function truckPayload(context) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
||||
return {
|
||||
transportType: 'PORT_PICKUP', origin: `验收起运-${suffix}`, originAddress: 'Long Beach',
|
||||
destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', containerType: '40HQ',
|
||||
palletCount: 1, packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88,
|
||||
currency: 'USD', contactName: '四期验收联系人', contactInfo: '13800000000',
|
||||
requestedDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), remark: context.marker,
|
||||
idempotencyKey: `${context.marker}-phase4-truck`.slice(0, 255),
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelMarkerOrder(context, resource, base, cancellableStatus, label) {
|
||||
if (!resource || resource.marker !== context.marker || !resource.orderNo || !longId(resource.customerId)) return
|
||||
const detailPath = query(`${base}/${encodeURIComponent(resource.orderNo)}`, { customerId: resource.customerId })
|
||||
const current = await overseas(context, 'GET', `清理前回查 marker ${label}订单`, detailPath)
|
||||
const currentSummary = summary(current.data)
|
||||
if (currentSummary?.status === 'CANCELLED') {
|
||||
resource.finalStatus = 'CANCELLED'; resource.version = Number(currentSummary.version); resource.retained = false
|
||||
return
|
||||
}
|
||||
if (currentSummary?.status !== cancellableStatus) {
|
||||
resource.finalStatus = currentSummary?.status || resource.finalStatus; resource.version = Number(currentSummary?.version); resource.retained = true
|
||||
coverageGap(context, `${label}测试数据最终处置`, `本轮 marker 资源 ${resource.orderNo} 当前状态 ${resource.finalStatus || 'missing'} 不允许安全取消;${trace(current.response)}`, 'unsafe-marker-terminal-state')
|
||||
return
|
||||
}
|
||||
const statusPath = query(`${base}/${encodeURIComponent(resource.orderNo)}/status`, { customerId: resource.customerId })
|
||||
const cancelled = await overseas(context, 'PATCH', `取消 marker ${label}订单`, statusPath,
|
||||
{ version: Number(currentSummary.version), status: 'CANCELLED', remark: `${context.marker}-acceptance-cleanup` })
|
||||
const cancelledSummary = summary(cancelled.data)
|
||||
const verified = await overseas(context, 'GET', `回查 marker ${label}订单最终状态`, detailPath)
|
||||
const finalSummary = summary(verified.data)
|
||||
const passed = cancelledSummary?.status === 'CANCELLED' && finalSummary?.status === 'CANCELLED'
|
||||
&& Number(finalSummary?.version) === Number(cancelledSummary?.version)
|
||||
context.assert(MODULE, `${label}本轮测试数据最终取消`, verified.response, passed,
|
||||
`orderNo=${resource.orderNo}, status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; statusCall=${trace(cancelled.response)}; verify=${trace(verified.response)}`, OPTIONS)
|
||||
resource.finalStatus = finalSummary?.status || cancelledSummary?.status || resource.finalStatus
|
||||
resource.version = Number(finalSummary?.version ?? cancelledSummary?.version)
|
||||
resource.retained = !passed
|
||||
if (!passed) coverageGap(context, `${label}测试数据最终处置`, `本轮 marker 资源 ${resource.orderNo} 取消失败并保留;statusCall=${trace(cancelled.response)};verify=${trace(verified.response)}`, 'cleanup-failed-retained-marker-test-data')
|
||||
}
|
||||
|
||||
async function truckFlow(context, customerId) {
|
||||
const base = `${BASE}/us-truck-orders`
|
||||
const payload = truckPayload(context)
|
||||
const created = await overseas(context, 'POST', '创建 marker 本土卡车订单', query(base, { customerId }), payload)
|
||||
const createdSummary = summary(created.data)
|
||||
const orderNo = id(createdSummary?.orderNo)
|
||||
context.assert(MODULE, '卡车创建返回本轮业务键和待审核状态', created.response,
|
||||
Boolean(orderNo) && id(createdSummary?.customerId) === customerId && createdSummary?.status === 'PENDING_REVIEW' && Number(createdSummary?.version) === 0,
|
||||
`orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!orderNo) return
|
||||
const registry = ownedRegistry(context)
|
||||
registry.truck = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true }
|
||||
const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId })
|
||||
try {
|
||||
const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path)
|
||||
const before = summary(detail.data)
|
||||
const editedRemark = `${context.marker}-truck-edited`
|
||||
const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path,
|
||||
{ version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: editedRemark })
|
||||
const after = summary(edited.data)
|
||||
context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response,
|
||||
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_REVIEW',
|
||||
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
context.addStep(MODULE, '卡车旧 version 编辑返回冲突', stale, conflict(stale), trace(stale), OPTIONS)
|
||||
const final = await overseas(context, 'GET', '冲突后回查 marker 本土卡车订单', path)
|
||||
const finalSummary = summary(final.data)
|
||||
context.assert(MODULE, '卡车冲突未造成部分更新', final.response,
|
||||
finalSummary?.destination === after?.destination && Number(finalSummary?.version) === Number(after?.version),
|
||||
`destination=${finalSummary?.destination || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS)
|
||||
registry.truck.version = Number(finalSummary?.version); registry.truck.finalStatus = finalSummary?.status
|
||||
} finally {
|
||||
await cancelMarkerOrder(context, registry.truck, base, 'PENDING_REVIEW', '卡车')
|
||||
}
|
||||
}
|
||||
|
||||
function looseboardPayload(context) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
||||
return {
|
||||
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
|
||||
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
|
||||
destinationAddress: 'Ontario', containerType: '20GP', packageCount: 3, totalWeightKg: 80,
|
||||
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
|
||||
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
|
||||
}
|
||||
}
|
||||
|
||||
async function looseboardFlow(context, customerId) {
|
||||
const base = `${BASE}/local-looseboard-orders`
|
||||
const payload = looseboardPayload(context)
|
||||
const created = await overseas(context, 'POST', '创建 marker 本土散板订单', query(base, { customerId }), payload)
|
||||
const createdSummary = summary(created.data)
|
||||
const orderNo = id(createdSummary?.orderNo)
|
||||
context.assert(MODULE, '散板创建返回本轮业务键和待接单状态', created.response,
|
||||
Boolean(orderNo) && id(createdSummary?.customerId) === customerId && createdSummary?.status === 'PENDING_ACCEPTANCE' && Number(createdSummary?.version) === 0,
|
||||
`orderNo=${orderNo || 'missing'}, customerId=${id(createdSummary?.customerId) || 'missing'}, status=${createdSummary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!orderNo) return
|
||||
const registry = ownedRegistry(context)
|
||||
registry.looseboard = { marker: context.marker, orderNo, customerId, id: id(createdSummary?.id), finalStatus: createdSummary.status, retained: true }
|
||||
const path = query(`${base}/${encodeURIComponent(orderNo)}`, { customerId })
|
||||
try {
|
||||
const detail = await overseas(context, 'GET', '查询 marker 本土散板详情', path)
|
||||
const before = summary(detail.data)
|
||||
const { idempotencyKey: _key, ...editable } = payload
|
||||
const edited = await overseas(context, 'PUT', '编辑 marker 本土散板订单', path,
|
||||
{ ...editable, version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-looseboard-edited` })
|
||||
const after = summary(edited.data)
|
||||
context.assert(MODULE, '散板编辑版本递增且普通字段回显', edited.response,
|
||||
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_ACCEPTANCE',
|
||||
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', path,
|
||||
{ ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
context.addStep(MODULE, '散板旧 version 编辑返回冲突', stale, conflict(stale), trace(stale), OPTIONS)
|
||||
const final = await overseas(context, 'GET', '冲突后回查 marker 本土散板订单', path)
|
||||
const finalSummary = summary(final.data)
|
||||
context.assert(MODULE, '散板冲突未造成部分更新', final.response,
|
||||
finalSummary?.destination === after?.destination && Number(finalSummary?.version) === Number(after?.version),
|
||||
`destination=${finalSummary?.destination || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS)
|
||||
registry.looseboard.version = Number(finalSummary?.version); registry.looseboard.finalStatus = finalSummary?.status
|
||||
} finally {
|
||||
await cancelMarkerOrder(context, registry.looseboard, base, 'PENDING_ACCEPTANCE', '散板')
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const prerequisite = await prerequisites(context)
|
||||
if (!prerequisite) return
|
||||
await procurementFlow(context, prerequisite)
|
||||
const customerId = id(prerequisite.activeCustomer.id)
|
||||
await truckFlow(context, customerId)
|
||||
await looseboardFlow(context, customerId)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { run } from './phase4-overseas-master-data.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8')
|
||||
const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin'
|
||||
const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data'
|
||||
const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' })
|
||||
const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' })
|
||||
|
||||
function detailQuote(state) {
|
||||
return {
|
||||
summary: { ...state.summary },
|
||||
warehouseFees: state.warehouseFees.map((item) => ({ ...item })),
|
||||
transitPrices: state.transitPrices.map((item) => ({ ...item })),
|
||||
createdBy: 'admin', createdAt: '2026-08-21T00:00:00',
|
||||
}
|
||||
}
|
||||
|
||||
function vehicleWire(state) {
|
||||
return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) }
|
||||
}
|
||||
|
||||
function fakeContext() {
|
||||
const calls = []
|
||||
const coverageGaps = []
|
||||
const marker = 'ADMIN-API-20260821123456789'
|
||||
const state = { warehouse: null, quote: null, exchange: null, vehicle: null }
|
||||
const context = {
|
||||
marker, calls, coverageGaps, createdResources: {},
|
||||
apiSuccess(_module, name, response) {
|
||||
assert.equal(response?.http, 200, `${name}: HTTP`)
|
||||
assert.equal(String(response?.body?.code), '200', `${name}: code`)
|
||||
return response.body.data
|
||||
},
|
||||
assert(_module, name, _response, condition, evidence) {
|
||||
assert.equal(Boolean(condition), true, `${name}: ${evidence}`)
|
||||
return true
|
||||
},
|
||||
expectConflict(_module, name, response) {
|
||||
assert.equal(response.http, 200, `${name}: HTTP`)
|
||||
assert.ok(['A0409', '409'].includes(String(response.body?.code)), `${name}: code=${response.body?.code}`)
|
||||
return true
|
||||
},
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata = {}) {
|
||||
coverageGaps.push({ module, scenario, name, reason, category, ...metadata })
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ gateway: 'domestic', method, path, body })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (method === 'POST' && path === DOMESTIC_BASE) {
|
||||
state.warehouse = { ...body, id: '910000000000000001' }
|
||||
return ok(state.warehouse.id)
|
||||
}
|
||||
if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities })
|
||||
}
|
||||
if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
state.warehouse = { ...body, id: state.warehouse.id }
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) {
|
||||
state.warehouse.status = 'INACTIVE'
|
||||
return ok(null)
|
||||
}
|
||||
assert.fail(`unexpected domestic call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseas(method, path, body) {
|
||||
calls.push({ gateway: 'overseas', method, path, body })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) {
|
||||
return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode,
|
||||
warehouseName: state.warehouse.warehouseName, countryCode: 'US' }])
|
||||
}
|
||||
if (method === 'POST' && path === `${OVERSEAS_BASE}/overseas-quotes`) {
|
||||
state.quote = {
|
||||
summary: { id: '920000000000000001', ...body, warehouseCode: state.warehouse.warehouseCode,
|
||||
warehouseName: state.warehouse.warehouseName, countryCode: 'US', status: 'INACTIVE', recordVersion: 0 },
|
||||
warehouseFees: body.warehouseFees, transitPrices: body.transitPrices || [],
|
||||
}
|
||||
return ok(detailQuote(state.quote))
|
||||
}
|
||||
if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) {
|
||||
if (method === 'GET') return ok(detailQuote(state.quote))
|
||||
if (method === 'PUT') {
|
||||
if (Number(body.recordVersion) !== state.quote.summary.recordVersion) return conflict()
|
||||
state.quote.summary = { ...state.quote.summary, name: body.name, providerName: body.providerName,
|
||||
currency: body.currency, effectiveStart: body.effectiveStart, effectiveEnd: body.effectiveEnd,
|
||||
remark: body.remark, recordVersion: state.quote.summary.recordVersion + 1 }
|
||||
state.quote.warehouseFees = body.warehouseFees
|
||||
state.quote.transitPrices = body.transitPrices || []
|
||||
return ok(detailQuote(state.quote))
|
||||
}
|
||||
}
|
||||
if (method === 'PATCH' && path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001/status`) {
|
||||
assert.equal(Number(body.recordVersion), state.quote.summary.recordVersion)
|
||||
state.quote.summary.status = body.status
|
||||
state.quote.summary.recordVersion += 1
|
||||
return ok(detailQuote(state.quote))
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) {
|
||||
return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
}
|
||||
if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) {
|
||||
state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 }
|
||||
return ok({ ...state.exchange })
|
||||
}
|
||||
if (state.exchange && path === `${OVERSEAS_BASE}/exchange-rates/${state.exchange.month}`) {
|
||||
if (method === 'GET') return ok({ ...state.exchange })
|
||||
if (method === 'PUT') {
|
||||
if (Number(body.recordVersion) !== state.exchange.recordVersion) return conflict()
|
||||
state.exchange = { ...state.exchange, usdToCnyAverage: body.usdToCnyAverage,
|
||||
remark: body.remark, recordVersion: state.exchange.recordVersion + 1 }
|
||||
return ok({ ...state.exchange })
|
||||
}
|
||||
}
|
||||
if (state.exchange && method === 'PATCH' && path === `${OVERSEAS_BASE}/exchange-rates/${state.exchange.month}/status`) {
|
||||
assert.equal(Number(body.recordVersion), state.exchange.recordVersion)
|
||||
state.exchange = { ...state.exchange, status: body.status, remark: body.remark ?? state.exchange.remark,
|
||||
recordVersion: state.exchange.recordVersion + 1 }
|
||||
return ok({ ...state.exchange })
|
||||
}
|
||||
if (method === 'POST' && path === `${OVERSEAS_BASE}/vehicles`) {
|
||||
state.vehicle = { id: '930000000000000001', ...body, recordVersion: 0 }
|
||||
return ok(vehicleWire(state))
|
||||
}
|
||||
if (path === `${OVERSEAS_BASE}/vehicles/930000000000000001`) {
|
||||
if (method === 'GET') return ok(vehicleWire(state))
|
||||
if (method === 'PUT') {
|
||||
if (Number(body.recordVersion) !== state.vehicle.recordVersion) return conflict()
|
||||
state.vehicle = { ...state.vehicle, ...body, recordVersion: state.vehicle.recordVersion + 1,
|
||||
status: state.vehicle.status }
|
||||
return ok(vehicleWire(state))
|
||||
}
|
||||
}
|
||||
if (method === 'PATCH' && path === `${OVERSEAS_BASE}/vehicles/930000000000000001/status`) {
|
||||
assert.equal(Number(body.recordVersion), state.vehicle.recordVersion)
|
||||
state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 }
|
||||
return ok(vehicleWire(state))
|
||||
}
|
||||
assert.fail(`unexpected overseas call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseasBinary(method, path) {
|
||||
calls.push({ gateway: 'overseas-binary', method, path })
|
||||
assert.equal(method, 'GET')
|
||||
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
|
||||
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'),
|
||||
contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' }
|
||||
},
|
||||
}
|
||||
return { context, state }
|
||||
}
|
||||
|
||||
test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
|
||||
assert.match(source, /context\.request\('POST', DOMESTIC_BASE/)
|
||||
assert.match(source, /warehouseType: 'OVERSEAS_PARTNER'/)
|
||||
assert.match(source, /context\.requestOverseas\('POST'/)
|
||||
assert.match(source, /context\.requestOverseas\('PUT'/)
|
||||
assert.match(source, /context\.requestOverseas\('PATCH'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /context\.expectConflict/)
|
||||
assert.match(source, /finally\s*\{/)
|
||||
assert.match(source, /context\.createdResources\.phase4OverseasMasterData/)
|
||||
assert.match(source, /context\.addCoverageGap/)
|
||||
assert.match(source, /2090 \+ Math\.floor/)
|
||||
assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/)
|
||||
for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export',
|
||||
'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint))
|
||||
})
|
||||
|
||||
test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => {
|
||||
const { context, state } = fakeContext()
|
||||
await run(context)
|
||||
const resource = context.createdResources.phase4OverseasMasterData
|
||||
assert.equal(resource.partnerWarehouse.id, '910000000000000001')
|
||||
assert.equal(resource.partnerWarehouse.finalStatus, 'INACTIVE')
|
||||
assert.equal(resource.quote.id, '920000000000000001')
|
||||
assert.equal(resource.quote.finalStatus, 'INACTIVE')
|
||||
assert.match(resource.exchangeRate.month, /^209\d-(0[1-9]|1[0-2])$/)
|
||||
assert.equal(resource.exchangeRate.finalStatus, 'INACTIVE')
|
||||
assert.equal(resource.vehicle.id, '930000000000000001')
|
||||
assert.equal(resource.vehicle.finalStatus, 'INACTIVE')
|
||||
assert.equal(typeof resource.vehicle.recordVersion, 'number')
|
||||
assert.equal(state.warehouse.status, 'INACTIVE')
|
||||
assert.equal(state.quote.summary.status, 'INACTIVE')
|
||||
assert.equal(state.exchange.status, 'INACTIVE')
|
||||
assert.equal(state.vehicle.status, 'INACTIVE')
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7)
|
||||
})
|
||||
|
||||
test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => {
|
||||
const { context, state } = fakeContext()
|
||||
const original = context.requestOverseas
|
||||
context.requestOverseas = async (method, path, body) => {
|
||||
if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([])
|
||||
return original(method, path, body)
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(state.warehouse.status, 'INACTIVE')
|
||||
assert.equal(state.quote, null)
|
||||
assert.equal(state.exchange.status, 'INACTIVE')
|
||||
assert.equal(state.vehicle.status, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true)
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.coverageGaps.length, 1)
|
||||
assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期')
|
||||
assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite')
|
||||
assert.match(context.coverageGaps[0].reason, /30分钟空缓存/)
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2)
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
const MODULE = 'phase4-overseas-master-data'
|
||||
const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin'
|
||||
const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data'
|
||||
const BLOCKER = { scenario: 'phase4-overseas-master-data-lifecycle', deploymentBlocker: true }
|
||||
|
||||
const success = (response) => response?.http === 200 && String(response?.body?.code) === '200'
|
||||
const data = (response) => success(response) ? response.body.data : null
|
||||
const recordVersion = (value) => Number.isInteger(Number(value)) && Number(value) >= 0
|
||||
const asId = (value) => value == null ? '' : String(value)
|
||||
|
||||
function requireData(context, name, response) {
|
||||
const value = context.apiSuccess(MODULE, name, response, '', BLOCKER)
|
||||
if (value == null) throw new Error(`${name}失败,生命周期无法继续`)
|
||||
return value
|
||||
}
|
||||
|
||||
function assertBinaryCsv(context, name, response) {
|
||||
context.assert(MODULE, name, response,
|
||||
response?.http === 200
|
||||
&& String(response.contentType || '').toLowerCase().startsWith('text/csv')
|
||||
&& response.bytes instanceof Uint8Array && response.bytes.length > 0,
|
||||
`HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`,
|
||||
BLOCKER)
|
||||
}
|
||||
|
||||
async function safeCleanup(context, name, action) {
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
context.assert(MODULE, `${name} finally停用`, null, false,
|
||||
error instanceof Error ? error.message : String(error), BLOCKER)
|
||||
}
|
||||
}
|
||||
|
||||
function warehousePayload(marker, edited = false) {
|
||||
const suffix = marker.replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase()
|
||||
return {
|
||||
tenantId: 0,
|
||||
warehouseCode: `P4-${suffix}`.slice(0, 50),
|
||||
warehouseName: edited ? `${marker} 合作仓-已编辑` : `${marker} 合作仓`,
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-8',
|
||||
contactName: 'Phase4 QA',
|
||||
contactPhone: '+1-213-555-0199',
|
||||
contactEmail: null,
|
||||
status: 'ACTIVE',
|
||||
remark: edited ? `${marker} lifecycle edited` : `${marker} lifecycle`,
|
||||
address: {
|
||||
addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California',
|
||||
city: 'Los Angeles', district: 'Commerce', postalCode: '90040',
|
||||
addressLine1: edited ? '402 Phase Four Test Avenue' : '401 Phase Four Test Avenue',
|
||||
addressLine2: null, latitude: null, longitude: null,
|
||||
},
|
||||
capabilities: [{
|
||||
capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true,
|
||||
cutoffTime: null, description: `${marker} acceptance capability`,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndVerifyPartnerWarehouse(context, resource) {
|
||||
const initial = warehousePayload(context.marker)
|
||||
const createResponse = await context.request('POST', DOMESTIC_BASE, initial)
|
||||
const createdId = asId(requireData(context, '新增OVERSEAS_PARTNER合作仓', createResponse))
|
||||
if (!createdId) throw new Error('合作仓创建响应未返回ID')
|
||||
resource.partnerWarehouse = { id: createdId, code: initial.warehouseCode, finalStatus: 'ACTIVE' }
|
||||
|
||||
const detailPath = `${DOMESTIC_BASE}/${encodeURIComponent(createdId)}?tenantId=0`
|
||||
const detailResponse = await context.request('GET', detailPath)
|
||||
const detail = requireData(context, '查询合作仓详情', detailResponse)
|
||||
context.assert(MODULE, '合作仓真实类型与marker', detailResponse,
|
||||
detail?.warehouse?.warehouseType === 'OVERSEAS_PARTNER'
|
||||
&& detail.warehouse.warehouseCode === initial.warehouseCode,
|
||||
`id=${createdId}, type=${detail?.warehouse?.warehouseType || 'missing'}, code=${detail?.warehouse?.warehouseCode || 'missing'}`,
|
||||
BLOCKER)
|
||||
|
||||
const edited = warehousePayload(context.marker, true)
|
||||
const editResponse = await context.request('PUT', detailPath, edited)
|
||||
context.apiSuccess(MODULE, '编辑合作仓(当前契约无version)', editResponse, 'warehouse SaveRequest 无乐观锁字段', BLOCKER)
|
||||
const afterResponse = await context.request('GET', detailPath)
|
||||
const after = requireData(context, '编辑后查询合作仓详情', afterResponse)
|
||||
context.assert(MODULE, '合作仓编辑持久化', afterResponse,
|
||||
after?.warehouse?.warehouseName === edited.warehouseName
|
||||
&& after?.warehouse?.remark === edited.remark
|
||||
&& after?.warehouse?.warehouseType === 'OVERSEAS_PARTNER',
|
||||
`name=${after?.warehouse?.warehouseName || 'missing'}, type=${after?.warehouse?.warehouseType || 'missing'}`,
|
||||
BLOCKER)
|
||||
}
|
||||
|
||||
function quoteCreatePayload(context, warehouseId) {
|
||||
return {
|
||||
name: `${context.marker} 海外报价`, warehouseId,
|
||||
providerName: `${context.marker} Provider`, currency: 'USD',
|
||||
effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31',
|
||||
versionCode: `${context.marker}-V1`.slice(0, 64),
|
||||
warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: context.marker }],
|
||||
transitPrices: [{ fbaWarehouseCode: 'ONT8', deliveryMethod: 'TRUCK', estimatedMiles: 12.5, price: 18.75, minimumCharge: 20, remark: context.marker }],
|
||||
remark: `${context.marker} lifecycle`,
|
||||
}
|
||||
}
|
||||
|
||||
function quoteUpdatePayload(created, context, version) {
|
||||
return {
|
||||
recordVersion: version, name: `${context.marker} 海外报价-已编辑`,
|
||||
providerName: created.providerName, currency: created.currency,
|
||||
effectiveStart: created.effectiveStart, effectiveEnd: created.effectiveEnd,
|
||||
warehouseFees: [{ item: 'Storage QA Edited', billingUnit: 'PALLET', unitPrice: 1.5, minimumCharge: 3, remark: `${context.marker} edited` }],
|
||||
transitPrices: created.transitPrices,
|
||||
remark: `${context.marker} lifecycle edited`,
|
||||
}
|
||||
}
|
||||
|
||||
async function quoteLifecycle(context, resource) {
|
||||
const optionsResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const options = requireData(context, '查询海外报价合作仓候选', optionsResponse)
|
||||
context.assert(MODULE, '报价候选只来自海外正式接口', optionsResponse,
|
||||
Array.isArray(options) && options.every((item) => asId(item.warehouseId)
|
||||
&& item.warehouseCode && item.warehouseName && item.countryCode),
|
||||
`count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER)
|
||||
if (Array.isArray(options) && options.length === 0) {
|
||||
resource.quote = {
|
||||
skipped: true,
|
||||
reason: 'warehouse-options-empty-cache',
|
||||
finalStatus: null,
|
||||
}
|
||||
context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期',
|
||||
'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。',
|
||||
'deployment-prerequisite', {
|
||||
marker: context.marker,
|
||||
partnerWarehouseId: resource.partnerWarehouse?.id || null,
|
||||
http: optionsResponse?.http ?? null,
|
||||
code: optionsResponse?.body?.code ?? null,
|
||||
requestId: optionsResponse?.requestId ?? null,
|
||||
traceId: optionsResponse?.traceId ?? null,
|
||||
})
|
||||
return false
|
||||
}
|
||||
const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id)
|
||||
const option = createdOption || options[0]
|
||||
if (!option) throw new Error('海外报价接口未返回可用合作仓候选')
|
||||
|
||||
const payload = quoteCreatePayload(context, asId(option.warehouseId))
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload)
|
||||
let detail = requireData(context, '新增海外报价', createResponse)
|
||||
const id = asId(detail?.summary?.id)
|
||||
if (!id || !recordVersion(detail?.summary?.recordVersion)) throw new Error('报价创建响应缺少字符串ID或recordVersion')
|
||||
resource.quote = { id, warehouseId: payload.warehouseId, recordVersion: Number(detail.summary.recordVersion), finalStatus: detail.summary.status }
|
||||
const path = `${OVERSEAS_BASE}/overseas-quotes/${encodeURIComponent(id)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
detail = requireData(context, '查询海外报价详情', detailResponse)
|
||||
context.assert(MODULE, '报价详情长ID、费用和版本', detailResponse,
|
||||
asId(detail?.summary?.id) === id && recordVersion(detail?.summary?.recordVersion)
|
||||
&& Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0,
|
||||
`id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const staleVersion = Number(detail.summary.recordVersion)
|
||||
const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion)
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
detail = requireData(context, '编辑海外报价', updateResponse)
|
||||
resource.quote.recordVersion = Number(detail.summary.recordVersion)
|
||||
context.assert(MODULE, '报价编辑版本递增', updateResponse,
|
||||
detail.summary.name === updatePayload.name && resource.quote.recordVersion === staleVersion + 1,
|
||||
`before=${staleVersion}, after=${resource.quote.recordVersion}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, name: `${context.marker} stale` })
|
||||
context.expectConflict(MODULE, '报价旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
detail = requireData(context, '报价冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '报价冲突未覆盖新值', afterConflictResponse,
|
||||
detail.summary.name === updatePayload.name && Number(detail.summary.recordVersion) === resource.quote.recordVersion,
|
||||
`name=${detail.summary.name}, version=${detail.summary.recordVersion}`, BLOCKER)
|
||||
|
||||
const activateResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
status: 'ACTIVE', recordVersion: resource.quote.recordVersion,
|
||||
})
|
||||
detail = requireData(context, '启用海外报价', activateResponse)
|
||||
resource.quote.recordVersion = Number(detail.summary.recordVersion)
|
||||
resource.quote.finalStatus = detail.summary.status
|
||||
context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse,
|
||||
detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
|
||||
assertBinaryCsv(context, '导出海外报价CSV', csv)
|
||||
return true
|
||||
}
|
||||
|
||||
async function chooseSafeExchangeMonth(context) {
|
||||
const digits = context.marker.replace(/\D/g, '')
|
||||
const seed = Number(digits.slice(-4) || '0')
|
||||
for (let offset = 0; offset < 24; offset += 1) {
|
||||
const serial = ((seed + offset) % 120)
|
||||
const month = `${2090 + Math.floor(serial / 12)}-${String((serial % 12) + 1).padStart(2, '0')}`
|
||||
const path = `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}`
|
||||
const response = await context.requestOverseas('GET', path)
|
||||
const page = requireData(context, `探测安全汇率月份${month}`, response)
|
||||
if (Array.isArray(page?.records) && page.records.length === 0 && Number(page.total) === 0) return month
|
||||
}
|
||||
throw new Error('2090-2099安全月份池均已占用,拒绝覆盖既有汇率')
|
||||
}
|
||||
|
||||
async function exchangeLifecycle(context, resource) {
|
||||
const month = await chooseSafeExchangeMonth(context)
|
||||
const createPayload = { month, usdToCnyAverage: 7.123456, remark: `${context.marker} safe marker month` }
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload)
|
||||
let rate = requireData(context, '新增安全marker月汇率', createResponse)
|
||||
resource.exchangeRate = { month, recordVersion: Number(rate.recordVersion), finalStatus: rate.status }
|
||||
const path = `${OVERSEAS_BASE}/exchange-rates/${encodeURIComponent(month)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
rate = requireData(context, '查询marker月汇率详情', detailResponse)
|
||||
context.assert(MODULE, '汇率详情月份、decimal与版本', detailResponse,
|
||||
rate.month === month && Number(rate.usdToCnyAverage) > 0 && recordVersion(rate.recordVersion),
|
||||
`month=${rate.month}, rate=${rate.usdToCnyAverage}, version=${rate.recordVersion}`, BLOCKER)
|
||||
|
||||
const staleVersion = Number(rate.recordVersion)
|
||||
const updatePayload = { recordVersion: staleVersion, usdToCnyAverage: 7.234567, remark: `${context.marker} overwritten once` }
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
rate = requireData(context, '覆盖维护marker月汇率', updateResponse)
|
||||
resource.exchangeRate.recordVersion = Number(rate.recordVersion)
|
||||
context.assert(MODULE, '汇率覆盖版本递增', updateResponse,
|
||||
Number(rate.usdToCnyAverage) === updatePayload.usdToCnyAverage
|
||||
&& resource.exchangeRate.recordVersion === staleVersion + 1,
|
||||
`before=${staleVersion}, after=${resource.exchangeRate.recordVersion}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 })
|
||||
context.expectConflict(MODULE, '汇率旧recordVersion覆盖冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
rate = requireData(context, '汇率冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '汇率冲突未覆盖新值', afterConflictResponse,
|
||||
Number(rate.usdToCnyAverage) === updatePayload.usdToCnyAverage
|
||||
&& Number(rate.recordVersion) === resource.exchangeRate.recordVersion,
|
||||
`rate=${rate.usdToCnyAverage}, version=${rate.recordVersion}`, BLOCKER)
|
||||
|
||||
const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
status: 'INACTIVE', recordVersion: resource.exchangeRate.recordVersion,
|
||||
remark: `${context.marker} lifecycle complete`,
|
||||
})
|
||||
rate = requireData(context, '停用marker月汇率', statusResponse)
|
||||
resource.exchangeRate.recordVersion = Number(rate.recordVersion)
|
||||
resource.exchangeRate.finalStatus = rate.status
|
||||
context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse,
|
||||
rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET',
|
||||
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
|
||||
assertBinaryCsv(context, '导出marker月汇率CSV', csv)
|
||||
}
|
||||
|
||||
function vehiclePayload(context, edited = false) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
||||
return {
|
||||
plateNumber: `QA-${suffix}`.slice(0, 64),
|
||||
model: edited ? 'Phase4 QA Truck Edited' : 'Phase4 QA Truck',
|
||||
fuelConsumptionPer100Km: edited ? 18.75 : 18.5,
|
||||
mileageKm: edited ? 101 : 100,
|
||||
driverOrDispatchCompany: `${context.marker} Carrier`, phone: '+1-213-555-0188',
|
||||
region: 'OVERSEAS', ownership: 'OWNED',
|
||||
...(edited ? {} : { status: 'IN_SERVICE' }),
|
||||
remark: edited ? `${context.marker} lifecycle edited` : `${context.marker} lifecycle`,
|
||||
}
|
||||
}
|
||||
|
||||
async function vehicleLifecycle(context, resource) {
|
||||
const createPayload = vehiclePayload(context)
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/vehicles`, createPayload)
|
||||
let vehicle = requireData(context, '新增marker车辆', createResponse)
|
||||
const id = asId(vehicle.id)
|
||||
if (!id || !recordVersion(vehicle.recordVersion)) throw new Error('车辆创建响应缺少字符串ID或recordVersion')
|
||||
resource.vehicle = { id, plateNumber: createPayload.plateNumber, recordVersion: Number(vehicle.recordVersion), finalStatus: vehicle.status }
|
||||
const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(id)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
vehicle = requireData(context, '查询marker车辆详情', detailResponse)
|
||||
context.assert(MODULE, '车辆详情长ID、五态字段与版本', detailResponse,
|
||||
asId(vehicle.id) === id && ['IN_SERVICE', 'DISPATCHED', 'MAINTENANCE', 'INACTIVE', 'RETURNED'].includes(vehicle.status)
|
||||
&& recordVersion(vehicle.recordVersion),
|
||||
`id=${asId(vehicle.id)}, status=${vehicle.status}, version=${vehicle.recordVersion}`, BLOCKER)
|
||||
|
||||
const staleVersion = Number(vehicle.recordVersion)
|
||||
const updatePayload = { recordVersion: staleVersion, ...vehiclePayload(context, true) }
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
vehicle = requireData(context, '编辑marker车辆', updateResponse)
|
||||
const updatedVersion = Number(vehicle.recordVersion)
|
||||
const updatedMileage = Number(vehicle.mileageKm)
|
||||
resource.vehicle.recordVersion = updatedVersion
|
||||
context.assert(MODULE, '车辆编辑版本递增', updateResponse,
|
||||
vehicle.model === updatePayload.model && updatedMileage === Number(updatePayload.mileageKm)
|
||||
&& updatedVersion === Number(staleVersion) + 1,
|
||||
`before=${staleVersion}, after=${updatedVersion}, mileage=${updatedMileage}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, model: 'stale overwrite' })
|
||||
context.expectConflict(MODULE, '车辆旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
vehicle = requireData(context, '车辆冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '车辆冲突未覆盖新值', afterConflictResponse,
|
||||
vehicle.model === updatePayload.model && Number(vehicle.recordVersion) === resource.vehicle.recordVersion,
|
||||
`model=${vehicle.model}, version=${vehicle.recordVersion}`, BLOCKER)
|
||||
|
||||
const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
status: 'MAINTENANCE', recordVersion: resource.vehicle.recordVersion,
|
||||
})
|
||||
vehicle = requireData(context, '车辆切换安全维护状态', statusResponse)
|
||||
resource.vehicle.recordVersion = Number(vehicle.recordVersion)
|
||||
resource.vehicle.finalStatus = vehicle.status
|
||||
context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse,
|
||||
vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv)
|
||||
}
|
||||
|
||||
async function cleanupPartnerWarehouse(context, resource) {
|
||||
if (!resource.partnerWarehouse?.id) return
|
||||
const response = await context.request('PATCH',
|
||||
`${DOMESTIC_BASE}/${encodeURIComponent(resource.partnerWarehouse.id)}/status?tenantId=0&status=INACTIVE`)
|
||||
context.apiSuccess(MODULE, '合作仓finally停用', response, `id=${resource.partnerWarehouse.id}`, BLOCKER)
|
||||
if (success(response)) resource.partnerWarehouse.finalStatus = 'INACTIVE'
|
||||
}
|
||||
|
||||
async function cleanupVersioned(context, resource, key, path, extract) {
|
||||
const current = resource[key]
|
||||
if (!current) return
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detail = data(detailResponse)
|
||||
const value = extract(detail)
|
||||
if (!success(detailResponse) || !value || !recordVersion(value.recordVersion)) {
|
||||
context.assert(MODULE, `${key} finally查询当前版本`, detailResponse, false,
|
||||
`HTTP=${detailResponse?.http ?? 0}, code=${detailResponse?.body?.code || 'missing'}`, BLOCKER)
|
||||
return
|
||||
}
|
||||
current.recordVersion = Number(value.recordVersion)
|
||||
current.finalStatus = value.status
|
||||
if (value.status === 'INACTIVE') return
|
||||
const response = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
status: 'INACTIVE', recordVersion: current.recordVersion,
|
||||
...(key === 'exchangeRate' ? { remark: `${context.marker} finally cleanup` } : {}),
|
||||
})
|
||||
const stopped = data(response)
|
||||
const normalized = extract(stopped)
|
||||
context.apiSuccess(MODULE, `${key} finally停用`, response, `path=${path}`, BLOCKER)
|
||||
if (normalized) {
|
||||
current.recordVersion = Number(normalized.recordVersion)
|
||||
current.finalStatus = normalized.status
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const resource = { marker: context.marker }
|
||||
context.createdResources.phase4OverseasMasterData = resource
|
||||
try {
|
||||
await createAndVerifyPartnerWarehouse(context, resource)
|
||||
await quoteLifecycle(context, resource)
|
||||
await exchangeLifecycle(context, resource)
|
||||
await vehicleLifecycle(context, resource)
|
||||
} finally {
|
||||
await safeCleanup(context, '海外报价', async () => {
|
||||
if (!resource.quote?.id) return
|
||||
const path = `${OVERSEAS_BASE}/overseas-quotes/${encodeURIComponent(resource.quote.id)}`
|
||||
await cleanupVersioned(context, resource, 'quote', path, (value) => value?.summary)
|
||||
})
|
||||
await safeCleanup(context, '月度汇率', async () => {
|
||||
if (!resource.exchangeRate?.month) return
|
||||
const path = `${OVERSEAS_BASE}/exchange-rates/${encodeURIComponent(resource.exchangeRate.month)}`
|
||||
await cleanupVersioned(context, resource, 'exchangeRate', path, (value) => value)
|
||||
})
|
||||
await safeCleanup(context, '车辆', async () => {
|
||||
if (!resource.vehicle?.id) return
|
||||
const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(resource.vehicle.id)}`
|
||||
await cleanupVersioned(context, resource, 'vehicle', path, (value) => value)
|
||||
})
|
||||
await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { run } from './phase4-overseas-warehouse.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8')
|
||||
|
||||
function response(data) { return { http: 200, body: { code: '200', message: 'success', data }, requestId: 'request-test', traceId: 'trace-test' } }
|
||||
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
|
||||
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
|
||||
const calls = []; const steps = []; const gaps = []; let createdPallet = null
|
||||
const context = {
|
||||
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
|
||||
async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) },
|
||||
async requestOverseas(method, requestPath, body) {
|
||||
calls.push({ channel: 'overseas', method, path: requestPath, body })
|
||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', ...body }; return response('71001') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { createdPallet = { ...createdPallet, ...body }; return response(null) }
|
||||
if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }]))
|
||||
if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] })
|
||||
if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }]))
|
||||
if (requestPath.includes('/relabel-applications/RL-1?')) return response({ application: { relabelNo: 'RL-1' }, items: [], history: [] })
|
||||
if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ id: '50001', sourceOrderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }]))
|
||||
if (requestPath.includes('/inbound-plans/IP-1?')) return response({ planNo: 'IP-1', sourceOrderId: '30001', lines: [], pallets: [], allocations: [] })
|
||||
if (requestPath.includes('/inventory/summary?')) return response({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 })
|
||||
if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }]))
|
||||
if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }])
|
||||
if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }]))
|
||||
if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
|
||||
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001' } })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
||||
throw new Error(`Unexpected path: ${requestPath}`)
|
||||
},
|
||||
async requestOverseasBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } },
|
||||
apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null },
|
||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
|
||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
test('模块只依赖注入请求,不读取环境或自行联网', () => {
|
||||
assert.match(source, /export async function run\(context\)/)
|
||||
assert.match(source, /context\.requestOverseas\('GET'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/)
|
||||
})
|
||||
|
||||
test('仅托盘生命周期允许写请求,其他海外业务保持只读', () => {
|
||||
assert.doesNotMatch(source, /requestOverseas\(['"]PATCH['"]/)
|
||||
const context = contextFixture()
|
||||
return run(context).then(() => {
|
||||
assert.ok(context.calls.length >= 12)
|
||||
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT'].includes(call.method)))
|
||||
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1)
|
||||
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
|
||||
const writes = context.calls.filter((call) => call.method !== 'GET')
|
||||
assert.deepEqual(writes.map((call) => call.method), ['POST', 'PUT', 'PUT'])
|
||||
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
||||
assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
|
||||
const context = contextFixture()
|
||||
await run(context)
|
||||
for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/pallets?']) {
|
||||
assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken)
|
||||
}
|
||||
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) {
|
||||
assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken)
|
||||
}
|
||||
assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001' })
|
||||
assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED' })
|
||||
})
|
||||
|
||||
test('缺少真实客户或仓库时记录 coverageGap,不使用固定业务ID补洞', async () => {
|
||||
const noCustomer = contextFixture({ tenants: [] })
|
||||
await run(noCustomer)
|
||||
assert.equal(noCustomer.gaps.length, 1)
|
||||
assert.match(noCustomer.gaps[0][3], /requestId=request-test.*traceId=trace-test/)
|
||||
assert.equal(noCustomer.calls.filter((call) => call.channel === 'overseas').length, 0)
|
||||
|
||||
const noWarehouse = contextFixture({ warehouseRecords: [] })
|
||||
await run(noWarehouse)
|
||||
assert.equal(noWarehouse.gaps.some((gap) => gap[2] === '公共托盘真实仓库前置'), true)
|
||||
assert.equal(noWarehouse.calls.some((call) => call.path.includes('/pallets')), false)
|
||||
})
|
||||
|
||||
test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => {
|
||||
assert.match(source, /inventory\/balances\/export/)
|
||||
assert.match(source, /inventory\/ledger\/export/)
|
||||
assert.match(source, /inventory\/summary[^\n]+customerId, page: 1, size: 200/)
|
||||
assert.match(source, /inventory\/balances\/export[^\n]+customerId, page: 1, size: 200/)
|
||||
assert.match(source, /inventory\/ledger\/export[^\n]+customerId, page: 1, size: 200/)
|
||||
for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden)
|
||||
assert.match(source, /requestId=/)
|
||||
assert.match(source, /traceId=/)
|
||||
assert.match(source, /longId\(plan\.sourceOrderId\)/)
|
||||
assert.match(source, /id\(pallet\.customerId\) === '0'/)
|
||||
assert.match(source, /换标创建与状态生命周期/)
|
||||
assert.match(source, /旧版本并发冲突/)
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
const MODULE = 'phase4-overseas-warehouse'
|
||||
const SCENARIO = 'phase4-overseas-warehouse'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
||||
function nonNegative(value) { return Number.isFinite(Number(value)) && Number(value) >= 0 }
|
||||
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
||||
function trace(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
}
|
||||
function query(path, params) {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
|
||||
return `${path}?${search}`
|
||||
}
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
function pageValid(page) {
|
||||
return Array.isArray(page?.records) && Number.isInteger(Number(page?.page)) && Number(page.page) >= 1
|
||||
&& Number.isInteger(Number(page?.size)) && Number(page.size) >= 1
|
||||
&& nonNegative(page?.total) && nonNegative(page?.pages)
|
||||
}
|
||||
function assertEnvelope(context, name, response) {
|
||||
context.assert(MODULE, `${name}响应包络正确`, response,
|
||||
response?.http === 200 && String(response?.body?.code) === '200' && Object.hasOwn(response?.body || {}, 'data'),
|
||||
trace(response), OPTIONS)
|
||||
}
|
||||
async function domesticGet(context, name, path) {
|
||||
const response = await context.request('GET', path)
|
||||
assertEnvelope(context, name, response)
|
||||
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
|
||||
}
|
||||
async function overseasGet(context, name, path) {
|
||||
const response = await context.requestOverseas('GET', path)
|
||||
assertEnvelope(context, name, response)
|
||||
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
|
||||
}
|
||||
async function overseasMutation(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
assertEnvelope(context, name, response)
|
||||
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
|
||||
}
|
||||
async function overseasCsv(context, name, path) {
|
||||
const response = await context.requestOverseasBinary('GET', path)
|
||||
context.addStep(MODULE, name, response,
|
||||
response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'),
|
||||
`bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS)
|
||||
return response
|
||||
}
|
||||
function assertPage(context, name, result) {
|
||||
context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data),
|
||||
`records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS)
|
||||
}
|
||||
function coverageGap(context, name, reason, category = 'prerequisite') {
|
||||
context.addCoverageGap(MODULE, SCENARIO, name, reason, category)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null }
|
||||
const tenantsResult = await domesticGet(context, '查询可用客户候选', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
|
||||
assertPage(context, '可用客户候选', tenantsResult)
|
||||
const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE')
|
||||
const customerId = id(customer?.id)
|
||||
if (!customerId) {
|
||||
coverageGap(context, '海外只读接口显式客户范围', `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`)
|
||||
return
|
||||
}
|
||||
|
||||
const scoped = { customerId, page: 1, size: 20 }
|
||||
const orders = await overseasGet(context, '查询尾程订单列表', query('/api/platform/v1/last-leg/orders', scoped))
|
||||
assertPage(context, '尾程订单列表', orders)
|
||||
const order = records(orders.data)[0]
|
||||
if (order) {
|
||||
context.assert(MODULE, '尾程订单关键字段正确', orders.response,
|
||||
longId(order.id) && nonBlank(order.orderNo) && nonBlank(order.currentStatus),
|
||||
`id=${id(order.id) || 'missing'}, orderNo=${order.orderNo || 'missing'}, status=${order.currentStatus || 'missing'}; ${trace(orders.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询尾程订单详情', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}`, { customerId }))
|
||||
context.assert(MODULE, '尾程订单详情对应列表记录', detail.response,
|
||||
id(detail.data?.order?.id) === id(order.id) && Array.isArray(detail.data?.cargo) && Array.isArray(detail.data?.statusHistory),
|
||||
`listId=${id(order.id)}, detailId=${id(detail.data?.order?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
const relabels = await overseasGet(context, '查询换标单列表', query('/api/platform/v1/last-leg/relabel-applications', scoped))
|
||||
assertPage(context, '换标单列表', relabels)
|
||||
const relabel = records(relabels.data)[0]
|
||||
if (relabel) {
|
||||
context.assert(MODULE, '换标单关键字段正确', relabels.response,
|
||||
longId(relabel.id) && nonBlank(relabel.relabelNo) && nonBlank(relabel.status),
|
||||
`id=${id(relabel.id) || 'missing'}, relabelNo=${relabel.relabelNo || 'missing'}, status=${relabel.status || 'missing'}; ${trace(relabels.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询换标单详情', query(`/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabel.relabelNo)}`, { customerId }))
|
||||
context.assert(MODULE, '换标单详情对应列表记录', detail.response,
|
||||
detail.data?.application?.relabelNo === relabel.relabelNo && Array.isArray(detail.data?.items) && Array.isArray(detail.data?.history),
|
||||
`listNo=${relabel.relabelNo}, detailNo=${detail.data?.application?.relabelNo || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
const plans = await overseasGet(context, '查询入仓计划列表', query('/api/platform/v1/last-leg/inbound-plans', scoped))
|
||||
assertPage(context, '入仓计划列表', plans)
|
||||
const plan = records(plans.data)[0]
|
||||
if (plan) {
|
||||
context.assert(MODULE, '入仓计划关键字段正确', plans.response,
|
||||
longId(plan.id) && longId(plan.sourceOrderId) && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status),
|
||||
`id=${id(plan.id) || 'missing'}, sourceOrderId=${id(plan.sourceOrderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询入仓计划详情', query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}`, { customerId }))
|
||||
context.assert(MODULE, '入仓计划详情对应列表记录', detail.response,
|
||||
detail.data?.planNo === plan.planNo && longId(detail.data?.sourceOrderId)
|
||||
&& Array.isArray(detail.data?.lines) && Array.isArray(detail.data?.pallets) && Array.isArray(detail.data?.allocations),
|
||||
`listNo=${plan.planNo}, detailNo=${detail.data?.planNo || 'missing'}, sourceOrderId=${id(detail.data?.sourceOrderId) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
const summary = await overseasGet(context, '查询库存汇总', query('/api/platform/v1/last-leg/inventory/summary', { customerId, page: 1, size: 200 }))
|
||||
context.assert(MODULE, '库存汇总关键数量非负', summary.response,
|
||||
['inboundOrderCount', 'palletCount', 'totalPackages', 'availablePackages', 'holdPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages']
|
||||
.every((field) => nonNegative(summary.data?.[field])),
|
||||
`total=${summary.data?.totalPackages ?? 'missing'}, available=${summary.data?.availablePackages ?? 'missing'}; ${trace(summary.response)}`, OPTIONS)
|
||||
|
||||
const balances = await overseasGet(context, '查询SKU库存余额', query('/api/platform/v1/last-leg/inventory/balances', scoped))
|
||||
assertPage(context, 'SKU库存余额', balances)
|
||||
const balance = records(balances.data)[0]
|
||||
if (balance) {
|
||||
context.assert(MODULE, 'SKU库存余额关键字段正确', balances.response,
|
||||
longId(balance.id) && id(balance.customerId) === customerId && nonBlank(balance.skuCode)
|
||||
&& nonNegative(balance.totalPackages) && nonNegative(balance.availablePackages) && nonBlank(balance.updatedAt),
|
||||
`id=${id(balance.id) || 'missing'}, sku=${balance.skuCode || 'missing'}, total=${balance.totalPackages ?? 'missing'}; ${trace(balances.response)}`, OPTIONS)
|
||||
const positions = await overseasGet(context, '查询库存库位详情', query(`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId }))
|
||||
context.assert(MODULE, '库存库位详情结构正确', positions.response,
|
||||
Array.isArray(positions.data) && positions.data.every((item) => longId(item?.id) && id(item?.balanceId) === id(balance.id) && nonNegative(item?.totalPackages)),
|
||||
`balanceId=${id(balance.id)}, positions=${positions.data?.length ?? 'missing'}; ${trace(positions.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
const ledger = await overseasGet(context, '查询进销存流水', query('/api/platform/v1/last-leg/inventory/ledger', scoped))
|
||||
assertPage(context, '进销存流水', ledger)
|
||||
const ledgerRow = records(ledger.data)[0]
|
||||
if (ledgerRow) context.assert(MODULE, '进销存流水关键字段正确', ledger.response,
|
||||
longId(ledgerRow.id) && id(ledgerRow.customerId) === customerId && nonBlank(ledgerRow.businessType)
|
||||
&& Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages))
|
||||
&& Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt),
|
||||
`id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS)
|
||||
await overseasCsv(context, '下载SKU库存余额CSV', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId, page: 1, size: 200 }))
|
||||
await overseasCsv(context, '下载进销存流水CSV', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId, page: 1, size: 200 }))
|
||||
|
||||
coverageGap(context, '换标创建与状态生命周期',
|
||||
'当前 Admin 换标新增/审核仍使用不可提交的契约提示弹框,前端未真实接入写 API;验收模块不越过页面范围直接创建换标单', 'frontend-scope')
|
||||
|
||||
const warehouse = await findWarehouse(context, records(tenantsResult.data), customerId)
|
||||
if (!warehouse) return
|
||||
const warehouseId = id(warehouse.id)
|
||||
context.createdResources.phase4OverseasWarehouse.customerId = customerId
|
||||
context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId
|
||||
const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 }))
|
||||
assertPage(context, '公共托盘列表', pallets)
|
||||
const pallet = records(pallets.data)[0]
|
||||
if (pallet) {
|
||||
context.assert(MODULE, '公共托盘关键字段正确', pallets.response,
|
||||
longId(pallet.id) && id(pallet.warehouseId) === warehouseId && id(pallet.customerId) === '0'
|
||||
&& nonBlank(pallet.palletCode) && ['IDLE', 'OCCUPIED', 'SCRAPPED'].includes(pallet.status),
|
||||
`id=${id(pallet.id) || 'missing'}, customerId=${id(pallet.customerId) || 'missing'}, status=${pallet.status || 'missing'}; ${trace(pallets.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`)
|
||||
context.assert(MODULE, '公共托盘详情对应列表记录', detail.response,
|
||||
id(detail.data?.id) === id(pallet.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0',
|
||||
`listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
await verifyPalletLifecycle(context, warehouseId)
|
||||
context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: context.createdResources.phase4OverseasWarehouse.palletId }
|
||||
}
|
||||
|
||||
async function verifyPalletLifecycle(context, warehouseId) {
|
||||
const marker = String(context.marker || '').replace(/[^A-Za-z0-9_-]/g, '').slice(-48) || 'PHASE4'
|
||||
const palletCode = `ACC-${marker}`.slice(0, 128)
|
||||
let palletId = ''
|
||||
try {
|
||||
const created = await overseasMutation(context, 'POST', '创建本轮公共托盘',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker,
|
||||
})
|
||||
palletId = id(created.data)
|
||||
context.createdResources.phase4OverseasWarehouse.palletId = palletId || null
|
||||
context.assert(MODULE, '公共托盘创建返回长ID', created.response, longId(palletId),
|
||||
`palletId=${palletId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!palletId) return
|
||||
|
||||
const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`
|
||||
const detail = await overseasGet(context, '查询本轮公共托盘详情', detailPath)
|
||||
context.assert(MODULE, '本轮公共托盘创建事实正确', detail.response,
|
||||
id(detail.data?.id) === palletId && id(detail.data?.warehouseId) === warehouseId
|
||||
&& id(detail.data?.customerId) === '0' && detail.data?.palletCode === palletCode && detail.data?.status === 'IDLE',
|
||||
`palletId=${id(detail.data?.id) || 'missing'}, code=${detail.data?.palletCode || 'missing'}, customerId=${id(detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
|
||||
const updated = await overseasMutation(context, 'PUT', '编辑本轮公共托盘', detailPath, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE',
|
||||
locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`,
|
||||
})
|
||||
context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null,
|
||||
`palletId=${palletId}; ${trace(updated.response)}`, OPTIONS)
|
||||
const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath)
|
||||
context.assert(MODULE, '公共托盘编辑字段已生效', after.response,
|
||||
id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5
|
||||
&& Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`,
|
||||
`capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS)
|
||||
coverageGap(context, '公共托盘旧版本并发冲突',
|
||||
'当前 Pallet SaveRequest/PUT 未提供 version 或 expectedVersion,无法构造稳定旧版本冲突且禁止伪造冲突请求', 'missing-version-contract')
|
||||
} finally {
|
||||
if (palletId) {
|
||||
const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED',
|
||||
remark: `${context.marker}-acceptance-retired`,
|
||||
})
|
||||
context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null
|
||||
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null,
|
||||
`palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS)
|
||||
const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
||||
context.assert(MODULE, '本轮公共托盘终态为已报废', retiredDetail.response,
|
||||
id(retiredDetail.data?.id) === palletId && retiredDetail.data?.status === 'SCRAPPED',
|
||||
`palletId=${id(retiredDetail.data?.id) || 'missing'}, status=${retiredDetail.data?.status || 'missing'}; ${trace(retiredDetail.response)}`, OPTIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findWarehouse(context, tenants, preferredCustomerId) {
|
||||
const candidates = [...new Set([preferredCustomerId, ...tenants.map((item) => id(item?.id)).filter(longId)])]
|
||||
let lastResponse = null
|
||||
for (const customerId of candidates) {
|
||||
const result = await overseasGet(context, `查询客户${customerId}可用海外仓`, query('/api/platform/v1/warehouse-admin', { customerId, page: 1, size: 100, status: 'ACTIVE' }))
|
||||
assertPage(context, `客户${customerId}可用海外仓`, result)
|
||||
lastResponse = result.response
|
||||
const warehouse = records(result.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE')
|
||||
if (warehouse) {
|
||||
context.assert(MODULE, '海外仓关键字段正确', result.response,
|
||||
nonBlank(warehouse.warehouseCode) && nonBlank(warehouse.warehouseName),
|
||||
`warehouseId=${id(warehouse.id)}, code=${warehouse.warehouseCode || 'missing'}; ${trace(result.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(warehouse.id))}`, { customerId }))
|
||||
context.assert(MODULE, '海外仓详情对应列表记录', detail.response,
|
||||
id(detail.data?.warehouse?.id || detail.data?.id) === id(warehouse.id),
|
||||
`listId=${id(warehouse.id)}, detailId=${id(detail.data?.warehouse?.id || detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
return warehouse
|
||||
}
|
||||
}
|
||||
coverageGap(context, '公共托盘真实仓库前置', `ACTIVE 客户列表未找到可用海外仓;${trace(lastResponse)}`, 'missing-readonly-warehouse')
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user