优化
This commit is contained in:
@@ -467,12 +467,13 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采
|
||||
|
||||
已知待验收缺口(不计作已覆盖通过):
|
||||
|
||||
- `phase4-overseas-dispatch` 要求两个 FBA 目的地及同目的地双实托;默认主链当前只产生单目的地(9/23 证据 `destinations=ABE4`),前置不足按 `notRun` 阻断,报告中 `dispatchStatus=NOT_RUN`,并连带 `phase5-overseas-fulfillment` 阻断。补齐主链多目的仓样本后重跑。
|
||||
- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID。
|
||||
- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次。
|
||||
- `fleet-management` 车次写命令需要本轮排车、车辆与司机资源。
|
||||
- Client 海外仓贴标、仓储转运、货物废弃和退货换标的创建链路需要平台海外 PDA 链产出的库存;当前只覆盖客户范围列表和退货预报写链路。
|
||||
- `phase4-overseas-dispatch`:主链已内置 `FBA-SECOND`(`destinationIndex=1`)强制第二 FBA 目的地;真实 HTTP 仍需 PDA 作业员凭据才能展开该变体。未验证前按 `notRun` 阻断并标记 `dispatchStatus=NOT_RUN`,连带 `phase5-overseas-fulfillment`。
|
||||
- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID;真实 HTTP 依赖 PDA 链产生工作事项。
|
||||
- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次;2026-10-20 空参 HTTP 返回 `B0001`,已按非阻断缺口记录。
|
||||
- `fleet-management` 只读(车次、出车记录、车队报价、拼车需求)已通过真实 HTTP;车次写命令仍需要本轮排车、车辆与司机资源。
|
||||
- Client 海外仓只读列表与退货预报写链路已通过真实 HTTP(2026-10-20 Client 全量 406 项通过、2 项跳过);贴标、仓储转运、货物废弃和退货换标的创建链路仍需要平台海外 PDA 链产出的库存。
|
||||
- `pda-releases` 真实 APK/WGT 上传、发布和停发需人工准备有效安装包。
|
||||
- Client 入库计划与派送单详情在新建客户无数据时跳过,需先由平台海外链产出单据后定向复核。
|
||||
|
||||
## 本地离线合同运行边界
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { run } from './appointment-work.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const moduleSource = readFileSync(new URL('./appointment-work.mjs', import.meta.url), 'utf8')
|
||||
|
||||
function fakeContext(overrides = {}) {
|
||||
const calls = []
|
||||
const gaps = []
|
||||
const steps = []
|
||||
const context = {
|
||||
marker: 'ADMIN-API-OFFLINE',
|
||||
calls,
|
||||
gaps,
|
||||
steps,
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
const url = new URL(path, 'https://offline.invalid')
|
||||
if (url.pathname.endsWith('/calendar')) {
|
||||
const from = url.searchParams.get('from')
|
||||
const to = url.searchParams.get('to')
|
||||
return { http: 200, body: { code: '200', data: { from, to, total: 2, days: [
|
||||
{ date: from, total: 1, typeCounts: { DELIVERY: 1 } },
|
||||
{ date: to, total: 1, typeCounts: { PICKUP: 1 } },
|
||||
] } } }
|
||||
}
|
||||
if (url.pathname.endsWith('/summary')) {
|
||||
return { http: 200, body: { code: '200', data: { all: 2, followToday: 1, overdue: 0, undated: 1, review: 0 } } }
|
||||
}
|
||||
if (url.pathname.endsWith('/items')) {
|
||||
return { http: 200, body: { code: '200', data: overrides.emptyPage
|
||||
? { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
: { records: [{ workItemKey: 'WORK-1', type: 'DELIVERY', flow: 'TRANSFER' }], page: 1, size: 20, total: 1, pages: 1 } } }
|
||||
}
|
||||
if (url.pathname.includes('/items/WORK-1')) {
|
||||
return { http: 200, body: { code: '200', data: {
|
||||
item: { workItemKey: 'WORK-1', type: 'DELIVERY', flow: 'TRANSFER', containers: [{ containerBookingId: 'C-1' }], allowedActions: [] },
|
||||
references: [], history: [],
|
||||
} } }
|
||||
}
|
||||
if (url.pathname.endsWith('/commands')) {
|
||||
return { http: 200, body: { code: 'A0404', message: '工作事项不存在' } }
|
||||
}
|
||||
throw new Error(`unexpected offline request ${method} ${path}`)
|
||||
},
|
||||
apiSuccess(module, name, response) {
|
||||
assert.equal(response.http, 200)
|
||||
assert.equal(String(response.body?.code), '200')
|
||||
steps.push({ module, name, passed: true })
|
||||
return response.body.data
|
||||
},
|
||||
assert(module, name, response, condition, evidence) {
|
||||
assert.equal(Boolean(condition), true, `${name}: ${evidence}`)
|
||||
steps.push({ module, name, passed: true })
|
||||
return true
|
||||
},
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata) {
|
||||
gaps.push({ module, scenario, name, reason, category, metadata })
|
||||
},
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
test('预约工作模块在默认全量中注册且只读查询官方工作事项接口', () => {
|
||||
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'appointment-work'/)
|
||||
assert.match(moduleSource, /\/api\/platform\/v1\/last-leg\/appointment-work/)
|
||||
for (const path of ['/calendar', '/summary', '/items', '/commands']) assert.match(moduleSource, new RegExp(path.replace('/', '\\/')))
|
||||
assert.match(moduleSource, /command: 'SAVE_FOLLOWUP'/)
|
||||
assert.doesNotMatch(moduleSource, /this\.addStep|writeFileSync|execSync/)
|
||||
})
|
||||
|
||||
test('预约工作只读链按日历、汇总、分页、详情顺序调用,唯一写入是不存在事项的拒绝命令', async () => {
|
||||
const context = fakeContext()
|
||||
await run(context)
|
||||
assert.deepEqual(context.calls.map((row) => row.method), ['GET', 'GET', 'GET', 'GET', 'POST'])
|
||||
assert.ok(context.calls[0].path.includes('/calendar?'))
|
||||
assert.ok(context.calls[1].path.endsWith('/summary'))
|
||||
assert.ok(context.calls[2].path.includes('/items?view=ALL&page=1&size=20'))
|
||||
assert.ok(context.calls[3].path.includes('/items/WORK-1'))
|
||||
assert.ok(context.calls[4].path.includes('/items/FULL-ACCEPTANCE-MISSING-WORK-ITEM/commands'))
|
||||
assert.equal(context.calls[4].body.command, 'SAVE_FOLLOWUP')
|
||||
assert.ok(context.gaps.some((gap) => gap.name === '预约写命令正向链路' && gap.metadata.blocking === false))
|
||||
})
|
||||
|
||||
test('无本轮工作事项时记录非阻断覆盖缺口而不是虚构详情或命令结果', async () => {
|
||||
const context = fakeContext({ emptyPage: true })
|
||||
await run(context)
|
||||
assert.equal(context.calls.length, 3)
|
||||
assert.ok(context.gaps.some((gap) => gap.name === '预约工作项详情与命令' && gap.metadata.blocking === false))
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
function count(value) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : NaN
|
||||
}
|
||||
|
||||
function shanghaiDate(offsetDays) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(new Date(Date.now() + offsetDays * 86_400_000))
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const module = 'appointment-work'
|
||||
const base = '/api/platform/v1/last-leg/appointment-work'
|
||||
const from = shanghaiDate(-7)
|
||||
const to = shanghaiDate(7)
|
||||
|
||||
const calendarResponse = await context.request('GET', `${base}/calendar?from=${from}&to=${to}`)
|
||||
const calendar = context.apiSuccess(module, '查询预约工作日历', calendarResponse)
|
||||
const days = Array.isArray(calendar?.days) ? calendar.days : []
|
||||
const dayTotal = days.reduce((sum, day) => sum + Number(day?.total || 0), 0)
|
||||
context.assert(module, '日历区间、日汇总与总数守恒', calendarResponse,
|
||||
calendar?.from === from && calendar?.to === to && count(calendar?.total) === dayTotal,
|
||||
`from=${calendar?.from || 'missing'},to=${calendar?.to || 'missing'},total=${calendar?.total ?? 'missing'},dayTotal=${dayTotal}`)
|
||||
for (const day of days.slice(0, 3)) {
|
||||
const typeTotal = Object.values(day?.typeCounts || {}).reduce((sum, value) => sum + Number(value || 0), 0)
|
||||
context.assert(module, `日历日 ${day?.date} 类型汇总不超过当日总数`, calendarResponse,
|
||||
Number(day?.total || 0) >= typeTotal,
|
||||
`date=${day?.date || 'missing'},total=${day?.total ?? 'missing'},typeTotal=${typeTotal}`)
|
||||
}
|
||||
|
||||
const summaryResponse = await context.request('GET', `${base}/summary`)
|
||||
const summary = context.apiSuccess(module, '查询预约工作汇总', summaryResponse)
|
||||
const summaryFields = ['all', 'followToday', 'overdue', 'undated', 'review']
|
||||
context.assert(module, '工作汇总五项计数完整', summaryResponse,
|
||||
summaryFields.every((field) => Number.isFinite(Number(summary?.[field]))),
|
||||
summaryFields.map((field) => `${field}=${summary?.[field] ?? 'missing'}`).join(','))
|
||||
|
||||
const pageResponse = await context.request('GET', `${base}/items?view=ALL&page=1&size=20`)
|
||||
const page = context.apiSuccess(module, '查询预约工作事项首页', pageResponse)
|
||||
const records = Array.isArray(page?.records) ? page.records : []
|
||||
const total = count(page?.total)
|
||||
context.assert(module, '工作事项分页完整且不截断', pageResponse,
|
||||
count(page?.page) === 1 && count(page?.size) === 20 && Number.isFinite(total)
|
||||
&& records.length === Math.min(20, total) && count(page?.pages) === Math.ceil(total / 20),
|
||||
`page=${page?.page},size=${page?.size},total=${page?.total},records=${records.length},pages=${page?.pages}`)
|
||||
|
||||
const first = records[0]
|
||||
if (!first) {
|
||||
context.addCoverageGap(module, 'appointment-work', '预约工作项详情与命令',
|
||||
'本轮主链未产生预约工作事项;需先完成订单、柜、船节点或排车链后重跑', 'not-implemented',
|
||||
{ blocking: false })
|
||||
return
|
||||
}
|
||||
|
||||
const detailResponse = await context.request('GET', `${base}/items/${encodeURIComponent(first.workItemKey)}`)
|
||||
const detail = context.apiSuccess(module, '查询当前工作事项详情', detailResponse)
|
||||
context.assert(module, '工作详情包含身份、来源与可执行动作', detailResponse,
|
||||
detail?.item?.workItemKey === first.workItemKey
|
||||
&& ['ROUTE', 'PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'].includes(detail?.item?.type)
|
||||
&& ['UNKNOWN', 'TRANSFER', 'DIRECT'].includes(detail?.item?.flow)
|
||||
&& Array.isArray(detail?.item?.containers) && detail.item.containers.length > 0
|
||||
&& Array.isArray(detail?.item?.allowedActions) && Array.isArray(detail?.references)
|
||||
&& Array.isArray(detail?.history),
|
||||
`key=${detail?.item?.workItemKey},type=${detail?.item?.type},flow=${detail?.item?.flow},`
|
||||
+ `actions=${detail?.item?.allowedActions?.length ?? 'missing'}`)
|
||||
|
||||
const invalidKey = 'FULL-ACCEPTANCE-MISSING-WORK-ITEM'
|
||||
const invalidResponse = await context.request('POST', `${base}/items/${encodeURIComponent(invalidKey)}/commands`, {
|
||||
command: 'SAVE_FOLLOWUP',
|
||||
payload: { ownerId: '1', followupDate: to, note: `missing-${context.marker}` },
|
||||
expectedVersion: 1,
|
||||
idempotencyKey: `${context.marker}-missing-work`,
|
||||
})
|
||||
context.assert(module, '不存在的工作事项命令被拒绝', invalidResponse,
|
||||
invalidResponse.http === 200 && ['A0403', 'A0404'].includes(String(invalidResponse.body?.code)),
|
||||
`HTTP=${invalidResponse.http},code=${invalidResponse.body?.code ?? 'missing'}`)
|
||||
|
||||
context.addCoverageGap(module, 'appointment-work', '预约写命令正向链路',
|
||||
'当前仅验证拒绝路径;SAVE_FOLLOWUP/CONFIRM_ROUTE/SAVE_REQUIREMENT 需要本轮可编辑工作项与操作人 ID,待下一批接入',
|
||||
'not-implemented', { blocking: false })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
export async function run(context) {
|
||||
const module = 'audit'
|
||||
const base = '/api/platform/v1/audit/logs'
|
||||
|
||||
const pageResponse = await context.request('GET', `${base}?page=1&size=20`)
|
||||
const page = context.apiSuccess(module, '查询平台操作日志首页', pageResponse)
|
||||
const records = Array.isArray(page?.records) ? page.records : []
|
||||
const total = Number(page?.total)
|
||||
context.assert(module, '日志分页完整且标识字段齐全', pageResponse,
|
||||
Number(page?.page) === 1 && Number(page?.size) === 20 && Number.isFinite(total)
|
||||
&& records.length === Math.min(20, total)
|
||||
&& records.every((row) => id(row.id) && row.eventType && row.occurredAt),
|
||||
`page=${page?.page},size=${page?.size},total=${page?.total},records=${records.length}`)
|
||||
context.assert(module, '日志列表不返回变更前后快照', pageResponse,
|
||||
records.every((row) => !Object.hasOwn(row, 'beforeData') && !Object.hasOwn(row, 'afterData')),
|
||||
`records=${records.length}`)
|
||||
|
||||
if (records[0]) {
|
||||
const detailResponse = await context.request('GET', `${base}/${encodeURIComponent(records[0].id)}`)
|
||||
const detail = context.apiSuccess(module, '查询当前日志详情', detailResponse)
|
||||
context.assert(module, '日志详情与列表身份一致', detailResponse,
|
||||
id(detail?.id) === id(records[0].id) && Object.hasOwn(detail || {}, 'httpMethod'),
|
||||
`id=${detail?.id},eventType=${detail?.eventType}`)
|
||||
} else {
|
||||
context.addCoverageGap(module, 'audit', '日志详情',
|
||||
'测试环境当前没有审计日志;先执行本轮业务写入后重跑', 'not-implemented', { blocking: false })
|
||||
}
|
||||
|
||||
const missingResponse = await context.request('GET', `${base}/999999999999999`)
|
||||
context.assert(module, '不存在的日志详情被拒绝', missingResponse,
|
||||
(missingResponse.http === 200 && ['A0403', 'A0404'].includes(String(missingResponse.body?.code)))
|
||||
|| missingResponse.http === 404,
|
||||
`HTTP=${missingResponse.http},code=${missingResponse.body?.code ?? 'missing'}`)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { run as runFleet } from './fleet-management.mjs'
|
||||
import { run as runAnalytics } from './self-operated-analytics.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
|
||||
function context(handler) {
|
||||
const calls = []
|
||||
const gaps = []
|
||||
return {
|
||||
marker: 'ADMIN-API-OFFLINE',
|
||||
calls,
|
||||
gaps,
|
||||
async request(method, path) {
|
||||
calls.push({ method, path })
|
||||
return handler(method, path)
|
||||
},
|
||||
apiSuccess(_module, _name, response) {
|
||||
assert.equal(response.http, 200)
|
||||
assert.equal(String(response.body?.code), '200')
|
||||
return response.body.data
|
||||
},
|
||||
assert(_module, name, _response, condition, evidence) {
|
||||
assert.equal(Boolean(condition), true, `${name}: ${evidence}`)
|
||||
return true
|
||||
},
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata) {
|
||||
gaps.push({ module, scenario, name, reason, category, metadata })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('车队管理模块注册为默认模块并只读核对车次、出车、报价与拼车需求', async () => {
|
||||
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'fleet-management'/)
|
||||
const tripRow = { tripNo: 'TRIP-1', status: 'PLANNED', recordVersion: 0 }
|
||||
const ctx = context((method, path) => {
|
||||
if (path.endsWith('/fleet/trips/FLEET-MISSING-TRIP')) {
|
||||
return { http: 200, body: { code: 'A0404', message: '车次不存在' } }
|
||||
}
|
||||
if (path.includes('/fleet/trips/TRIP-1')) {
|
||||
return { http: 200, body: { code: '200', data: {
|
||||
trip: tripRow, dispatches: [], loadingReview: { recordVersion: null }, previousDispatches: [] } } }
|
||||
}
|
||||
if (path.includes('/fleet/trips')) {
|
||||
return { http: 200, body: { code: '200', data: { records: [tripRow], page: 1, size: 20, total: 1, pages: 1 } } }
|
||||
}
|
||||
if (path.includes('/fleet/operations')) {
|
||||
return { http: 200, body: { code: '200', data: {
|
||||
page: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0,
|
||||
externalPackages: 0, externalAgreedAmounts: [], costs: { visible: true, unrecordedTrips: null, unallocatedTrips: null, totals: [] } },
|
||||
charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] },
|
||||
} } }
|
||||
}
|
||||
if (path.includes('/fleet/quotes')) {
|
||||
return { http: 200, body: { code: '200', data: { records: [{ quoteNo: 'Q-1', status: 'ACTIVE', recordVersion: '0' }], page: 1, size: 20, total: 1, pages: 1 } } }
|
||||
}
|
||||
if (path.includes('/fleet/capacity-offers')) {
|
||||
return { http: 200, body: { code: '200', data: { records: [{ status: 'OPEN' }], page: 1, size: 20, total: 1, pages: 1 } } }
|
||||
}
|
||||
if (path.endsWith('/fleet/trips/FLEET-MISSING-TRIP')) {
|
||||
return { http: 200, body: { code: 'A0404', message: '车次不存在' } }
|
||||
}
|
||||
throw new Error(`unexpected ${method} ${path}`)
|
||||
})
|
||||
await runFleet(ctx)
|
||||
assert.deepEqual(ctx.calls.map((row) => row.method), ['GET', 'GET', 'GET', 'GET', 'GET', 'GET'])
|
||||
assert.ok(ctx.calls[0].path.includes('/fleet/trips?page=1&size=20'))
|
||||
assert.ok(ctx.calls[2].path.includes('/fleet/operations?page=1&size=20&from='))
|
||||
assert.ok(ctx.calls[4].path.includes('/fleet/capacity-offers'))
|
||||
assert.ok(ctx.gaps.some((gap) => gap.name === '车次写命令' && gap.metadata.blocking === false))
|
||||
})
|
||||
|
||||
test('自营分析模块注册为默认模块,空参不可用时记录缺口而不是虚构看板', async () => {
|
||||
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'self-operated-analytics'/)
|
||||
const unavailable = context(() => ({ http: 200, body: { code: 'A0400', message: '缺少采集账号' } }))
|
||||
await runAnalytics(unavailable)
|
||||
assert.equal(unavailable.calls.length, 2)
|
||||
assert.ok(unavailable.gaps.some((gap) => gap.name === '经营看板' && gap.metadata.blocking === false))
|
||||
assert.ok(unavailable.gaps.some((gap) => gap.name === '货主目录' && gap.metadata.blocking === false))
|
||||
|
||||
const available = context((_method, path) => path.endsWith('/dashboard')
|
||||
? { http: 200, body: { code: '200', data: { metrics: [], trend: [] } } }
|
||||
: { http: 200, body: { code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } } })
|
||||
await runAnalytics(available)
|
||||
assert.equal(available.gaps.some((gap) => gap.name === '经营看板'), false)
|
||||
assert.ok(available.gaps.some((gap) => gap.category === 'external-third-party' && gap.metadata.blocking === false))
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
function count(value) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : NaN
|
||||
}
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
function isoInstant(offsetDays, endOfDay = false) {
|
||||
const day = new Date(Date.now() + offsetDays * 86_400_000).toISOString().slice(0, 10)
|
||||
return `${day}T${endOfDay ? '23:59:59' : '00:00:00'}Z`
|
||||
}
|
||||
|
||||
function pageOf(value) {
|
||||
return value && Array.isArray(value.records) && Number.isFinite(count(value.total))
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const module = 'fleet-management'
|
||||
const base = '/api/platform/v1/last-leg/fleet'
|
||||
|
||||
const tripsResponse = await context.request('GET', `${base}/trips?page=1&size=20`)
|
||||
const trips = context.apiSuccess(module, '查询车次计划列表', tripsResponse)
|
||||
context.assert(module, '车次分页与状态字段完整', tripsResponse,
|
||||
pageOf(trips) && trips.records.every((row) => id(row.tripNo) && row.status && Number.isFinite(count(row.recordVersion))),
|
||||
`total=${trips?.total ?? 'missing'},records=${trips?.records?.length ?? 'missing'}`)
|
||||
|
||||
const tripNo = trips?.records?.[0]?.tripNo
|
||||
if (tripNo) {
|
||||
const detailResponse = await context.request('GET', `${base}/trips/${encodeURIComponent(tripNo)}`)
|
||||
const detail = context.apiSuccess(module, '查询当前车次详情', detailResponse)
|
||||
context.assert(module, '车次详情包含四页签所需事实', detailResponse,
|
||||
id(detail?.trip?.tripNo) === id(tripNo)
|
||||
&& detail?.trip?.status
|
||||
&& Array.isArray(detail?.dispatches)
|
||||
&& detail?.loadingReview != null,
|
||||
`tripNo=${detail?.trip?.tripNo},status=${detail?.trip?.status},dispatches=${detail?.dispatches?.length ?? 'missing'}`)
|
||||
} else {
|
||||
context.addCoverageGap(module, 'fleet-management', '车次详情',
|
||||
'当前没有车次样本;先由海外排车/车队入口创建本轮车次后重跑', 'not-implemented', { blocking: false })
|
||||
}
|
||||
|
||||
const operationsResponse = await context.request('GET',
|
||||
`${base}/operations?page=1&size=20&from=${isoInstant(-30)}&to=${isoInstant(0, true)}`)
|
||||
const operations = context.apiSuccess(module, '查询出车记录', operationsResponse)
|
||||
context.assert(module, '出车记录分页、汇总与图表结构完整', operationsResponse,
|
||||
pageOf(operations?.page)
|
||||
&& Number.isFinite(count(operations?.summary?.trips))
|
||||
&& Array.isArray(operations?.summary?.externalAgreedAmounts)
|
||||
&& operations?.charts && Array.isArray(operations.charts.physicalPallets),
|
||||
`trips=${operations?.summary?.trips ?? 'missing'},pageTotal=${operations?.page?.total ?? 'missing'}`)
|
||||
context.assert(module, '未登记或未分摊成本不按零展示', operationsResponse,
|
||||
operations?.summary?.costs == null
|
||||
|| (typeof operations.summary.costs.visible === 'boolean'
|
||||
&& (operations.summary.costs.unrecordedTrips == null || Number.isFinite(count(operations.summary.costs.unrecordedTrips)))),
|
||||
JSON.stringify(operations?.summary?.costs || {}).slice(0, 200))
|
||||
|
||||
const quotesResponse = await context.request('GET', `${base}/quotes?page=1&size=20`)
|
||||
const quotes = context.apiSuccess(module, '查询车队报价', quotesResponse)
|
||||
context.assert(module, '车队报价分页字段完整', quotesResponse,
|
||||
pageOf(quotes) && quotes.records.every((row) => id(row.quoteNo) && row.status != null
|
||||
&& (row.recordVersion == null || Number.isFinite(count(row.recordVersion)))),
|
||||
`total=${quotes?.total ?? 'missing'},records=${quotes?.records?.length ?? 'missing'}`)
|
||||
|
||||
const offersResponse = await context.request('GET', `${base}/capacity-offers?page=1&size=20`)
|
||||
const offers = context.apiSuccess(module, '查询内部拼车需求池', offersResponse)
|
||||
context.assert(module, '拼车需求分页字段完整', offersResponse,
|
||||
pageOf(offers) && offers.records.every((row) => row.status != null),
|
||||
`total=${offers?.total ?? 'missing'},records=${offers?.records?.length ?? 'missing'}`)
|
||||
|
||||
const missingResponse = await context.request('GET', `${base}/trips/FLEET-MISSING-TRIP`)
|
||||
context.assert(module, '不存在的车次详情被拒绝', missingResponse,
|
||||
(missingResponse.http === 200 && ['A0403', 'A0404'].includes(String(missingResponse.body?.code)))
|
||||
|| missingResponse.http === 404,
|
||||
`HTTP=${missingResponse.http},code=${missingResponse.body?.code ?? 'missing'}`)
|
||||
|
||||
context.addCoverageGap(module, 'fleet-management', '车次写命令',
|
||||
'车次计划/分配/审核/发车/结案依赖本轮排车与车辆资源;本模块只读核对列表、详情、出车记录、报价和拼车需求',
|
||||
'not-implemented', { blocking: false })
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { run as runPdaReleases } from './pda-releases.mjs'
|
||||
import { run as runAudit } from './audit.mjs'
|
||||
import { run as runSailingCalendar } from './sailing-calendar.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
|
||||
function context(handler) {
|
||||
const calls = []
|
||||
const gaps = []
|
||||
return {
|
||||
marker: 'ADMIN-API-OFFLINE',
|
||||
calls,
|
||||
gaps,
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
return handler(method, path, body)
|
||||
},
|
||||
async requestMultipart(method, path, form) {
|
||||
calls.push({ method, path, multipart: form })
|
||||
return handler(method, path, form)
|
||||
},
|
||||
apiSuccess(_module, _name, response) {
|
||||
assert.equal(response.http, 200)
|
||||
assert.equal(String(response.body?.code), '200')
|
||||
return response.body.data
|
||||
},
|
||||
assert(_module, name, _response, condition, evidence) {
|
||||
assert.equal(Boolean(condition), true, `${name}: ${evidence}`)
|
||||
return true
|
||||
},
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata) {
|
||||
gaps.push({ module, scenario, name, reason, category, metadata })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('PDA 版本模块注册为默认模块并只读核对双端发布列表', async () => {
|
||||
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'pda-releases'/)
|
||||
const releases = (app) => [{
|
||||
id: '1', app, appid: `__UNI__${app}`, packageType: 'apk', versionName: '1.0.0', versionCode: 1,
|
||||
minAppVersionCode: 1, mandatory: false, releaseNotes: 'offline', status: 'PUBLISHED', current: true,
|
||||
sha256: 'a'.repeat(64), sizeBytes: 1024, downloadUrl: '/download', createdAt: '2026-10-01T00:00:00Z',
|
||||
publishedAt: '2026-10-01T00:00:00Z',
|
||||
}]
|
||||
const ctx = context((method, path) => {
|
||||
if (method === 'GET') return { http: 200, body: { code: '200', data: releases(path.endsWith('/domestic') ? 'domestic' : 'overseas') } }
|
||||
if (method === 'POST' && path.includes('/publish')) return { http: 200, body: { code: 'A0404', message: '不存在' } }
|
||||
if (method === 'POST' && path.includes('/withdraw')) return { http: 200, body: { code: 'A0404', message: '不存在' } }
|
||||
return { http: 400, body: { code: 'A0400', message: '文件不能为空' } }
|
||||
})
|
||||
await runPdaReleases(ctx)
|
||||
assert.deepEqual(ctx.calls.map((row) => row.method), ['GET', 'GET', 'POST', 'POST', 'POST'])
|
||||
assert.ok(ctx.calls[0].path.endsWith('/domestic'))
|
||||
assert.ok(ctx.calls[1].path.endsWith('/overseas'))
|
||||
assert.ok(ctx.gaps.some((gap) => gap.name === '真实 APK/WGT 上传、发布、停发与下载' && gap.metadata.blocking === false))
|
||||
})
|
||||
|
||||
test('平台审计模块注册为默认模块,列表不泄漏快照且详情身份一致', async () => {
|
||||
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'audit'/)
|
||||
const row = { id: '7', eventId: 'e-7', eventType: 'ORDER_SUBMIT', resourceType: 'ORDER', resourceId: '1',
|
||||
accountId: '0', operatorTenantId: '0', userId: '1', occurredAt: '2026-10-01T00:00:00Z', success: true }
|
||||
const detailCtx = context((method, path) => {
|
||||
if (path.endsWith('/999999999999999')) return { http: 200, body: { code: 'A0404', message: '不存在' } }
|
||||
if (path.endsWith('/7')) return { http: 200, body: { code: '200', data: { ...row, httpMethod: 'POST' } } }
|
||||
return { http: 200, body: { code: '200', data: { records: [row], page: 1, size: 20, total: 1, pages: 1 } } }
|
||||
})
|
||||
const withSnapshot = context((method, path) => {
|
||||
if (path.includes('/audit/logs/')) return { http: 200, body: { code: 'A0404', message: '不存在' } }
|
||||
return { http: 200, body: { code: '200', data: { records: [{ ...row, beforeData: '{}' }], page: 1, size: 20, total: 1, pages: 1 } } }
|
||||
})
|
||||
await runAudit(detailCtx)
|
||||
assert.equal(detailCtx.calls[0].method, 'GET')
|
||||
assert.ok(detailCtx.calls[0].path.includes('/audit/logs?page=1&size=20'))
|
||||
assert.equal(detailCtx.calls[1].path, '/api/platform/v1/audit/logs/7')
|
||||
assert.ok(detailCtx.calls[2].path.endsWith('/999999999999999'))
|
||||
await assert.rejects(runAudit(withSnapshot), /不返回变更前后快照/)
|
||||
})
|
||||
|
||||
test('船期日历模块注册为默认模块并核对月历、渠道、模板与异常分配', async () => {
|
||||
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'sailing-calendar'/)
|
||||
const summary = {
|
||||
actualReceivedCbm: 1, catchableForecastCbm: 2, riskUnmatchedCbm: 0, bookedContainerCount: 1,
|
||||
recommendedContainerCount: 1, pendingContainerCount: 0, lateBatchCount: 0, overCapacity: false,
|
||||
planningCapacityCbm: 10, bookedPlanningCapacityCbm: 5, catchableReceivedCbm: 1, lateReceivedCbm: 0,
|
||||
snapshotAt: null, sailingCount: 1,
|
||||
}
|
||||
const sailing = {
|
||||
id: '11', sailingNo: 'SAIL-11', warehouseId: '1', channelCode: 'SEA', channelName: '海运', templateId: null,
|
||||
channelId: null, etaAt: '2026-10-20T00:00:00', sourceType: 'MANUAL', plannedCapacityCbm: 10,
|
||||
volumeSummary: summary, version: 0,
|
||||
}
|
||||
const ctx = context((method, path) => {
|
||||
if (path.includes('/calendar')) return { http: 200, body: { code: '200', data: {
|
||||
records: [sailing], summary, filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] } } } }
|
||||
if (path.includes('/sailing-channels')) return { http: 200, body: { code: '200', data: [{ id: '1', channelCode: 'SEA', defaultCutoffDays: 3, version: 0 }] } }
|
||||
if (path.includes('/sailing-templates')) return { http: 200, body: { code: '200', data: { records: [{ id: '1', channelId: '1', warehouseId: '1', weekdays: [1, 2], cutoffDays: 3, version: 0 }] } } }
|
||||
if (path.includes('/sailing-assignments/exceptions')) return { http: 200, body: { code: '200', data: { records: [{ assignmentId: '1', volumeCbm: 1 }], total: 1 } } }
|
||||
if (path.endsWith('/11')) return { http: 200, body: { code: '200', data: {
|
||||
sailing: { ...sailing, volumeSummary: summary }, histories: [] } } }
|
||||
if (path.endsWith('/999999999999999')) return { http: 200, body: { code: 'A0404', message: '不存在' } }
|
||||
throw new Error(`unexpected ${method} ${path}`)
|
||||
})
|
||||
await runSailingCalendar(ctx)
|
||||
assert.deepEqual(ctx.calls.map((row) => row.method), ['GET', 'GET', 'GET', 'GET', 'GET', 'GET'])
|
||||
assert.ok(ctx.calls[0].path.includes('/sailings/calendar?month='))
|
||||
assert.ok(ctx.calls[1].path.endsWith('/sailing-channels'))
|
||||
assert.ok(ctx.calls[2].path.includes('/sailing-templates?page=1&size=200'))
|
||||
assert.ok(ctx.calls[3].path.includes('/sailing-assignments/exceptions'))
|
||||
assert.ok(ctx.calls[4].path.endsWith('/11'))
|
||||
assert.ok(ctx.calls[5].path.endsWith('/999999999999999'))
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
function validRelease(row) {
|
||||
return row && typeof row === 'object'
|
||||
&& ['domestic', 'overseas'].includes(row.app)
|
||||
&& ['apk', 'wgt'].includes(row.packageType)
|
||||
&& typeof row.versionName === 'string' && row.versionName.length > 0
|
||||
&& Number.isSafeInteger(Number(row.versionCode))
|
||||
&& ['DRAFT', 'PUBLISHED', 'WITHDRAWN'].includes(row.status)
|
||||
&& typeof row.current === 'boolean'
|
||||
&& /^[a-f0-9]{64}$/i.test(String(row.sha256 || ''))
|
||||
&& Number.isSafeInteger(Number(row.sizeBytes)) && Number(row.sizeBytes) > 0
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const module = 'pda-releases'
|
||||
const base = '/api/platform/v1/files/pda-releases'
|
||||
|
||||
for (const app of ['domestic', 'overseas']) {
|
||||
const response = await context.request('GET', `${base}/${app}`)
|
||||
const releases = context.apiSuccess(module, `查询 ${app} PDA 发布列表`, response)
|
||||
context.assert(module, `${app} 发布列表结构与版本字段完整`, response,
|
||||
Array.isArray(releases) && releases.every(validRelease),
|
||||
`count=${Array.isArray(releases) ? releases.length : 'not-array'}`)
|
||||
const currentCount = Array.isArray(releases) ? releases.filter((row) => row.current === true).length : -1
|
||||
context.assert(module, `${app} 当前生效版本不超过一个发布类型`, response,
|
||||
currentCount <= 2, `current=${currentCount}`)
|
||||
}
|
||||
|
||||
const emptyUpload = await context.requestMultipart('POST', `${base}/domestic`, new FormData())
|
||||
context.assert(module, '无安装包的上传被拒绝', emptyUpload,
|
||||
emptyUpload.http >= 400 || String(emptyUpload.body?.code) !== '200',
|
||||
`HTTP=${emptyUpload.http},code=${emptyUpload.body?.code ?? 'missing'}`)
|
||||
|
||||
const missingId = '999999999999999'
|
||||
for (const action of ['publish', 'withdraw']) {
|
||||
const response = await context.request('POST', `${base}/${missingId}/${action}`, {})
|
||||
context.assert(module, `不存在版本 ${action} 被拒绝`, response,
|
||||
response.http === 200 && ['A0403', 'A0404', 'A0409'].includes(String(response.body?.code)),
|
||||
`HTTP=${response.http},code=${response.body?.code ?? 'missing'}`)
|
||||
}
|
||||
|
||||
context.addCoverageGap(module, 'pda-releases', '真实 APK/WGT 上传、发布、停发与下载',
|
||||
'脚本不上传真实安装包;需人工准备有效 domestic/overseas APK 或 WGT 后定向执行,避免伪造签名与版本号',
|
||||
'not-implemented', { blocking: false })
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
function count(value) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : NaN
|
||||
}
|
||||
|
||||
function currentMonth() {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit',
|
||||
}).format(new Date()).slice(0, 7)
|
||||
}
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
function listOf(value) {
|
||||
if (Array.isArray(value)) return value
|
||||
if (Array.isArray(value?.records)) return value.records
|
||||
return null
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const module = 'sailing-calendar'
|
||||
const base = '/api/platform/v1/sailings'
|
||||
const month = currentMonth()
|
||||
|
||||
const calendarResponse = await context.request('GET', `${base}/calendar?month=${month}`)
|
||||
const calendar = context.apiSuccess(module, '查询船期月历', calendarResponse)
|
||||
const records = Array.isArray(calendar?.records) ? calendar.records : []
|
||||
const summary = calendar?.summary
|
||||
const filterOptions = calendar?.filterOptions
|
||||
context.assert(module, '月历记录、汇总与筛选选项结构完整', calendarResponse,
|
||||
records.every((row) => id(row.id) && id(row.sailingNo) && 'etaAt' in row)
|
||||
&& summary && Number.isFinite(count(summary.sailingCount))
|
||||
&& filterOptions && Array.isArray(filterOptions.warehouses) && Array.isArray(filterOptions.channels),
|
||||
`month=${month},records=${records.length},sailingCount=${summary?.sailingCount ?? 'missing'}`)
|
||||
context.assert(module, '月历容量汇总均为非负有限数', calendarResponse,
|
||||
['actualReceivedCbm', 'catchableForecastCbm', 'riskUnmatchedCbm'].every((field) =>
|
||||
Number.isFinite(count(summary?.[field])) && count(summary?.[field]) >= 0),
|
||||
JSON.stringify(summary || {}).slice(0, 200))
|
||||
|
||||
const channelsResponse = await context.request('GET', '/api/platform/v1/sailing-channels')
|
||||
const channels = context.apiSuccess(module, '查询船期渠道资源', channelsResponse)
|
||||
const channelRows = listOf(channels) ?? []
|
||||
context.assert(module, '渠道列表字段完整', channelsResponse,
|
||||
channelRows.every((row) => id(row.id) && id(row.channelCode) && Number.isFinite(count(row.defaultCutoffDays))),
|
||||
`count=${channelRows.length}`)
|
||||
|
||||
const templatesResponse = await context.request('GET', '/api/platform/v1/sailing-templates?page=1&size=200')
|
||||
const templates = context.apiSuccess(module, '查询船期模板', templatesResponse)
|
||||
const templateRows = listOf(templates) ?? []
|
||||
context.assert(module, '模板列表字段完整', templatesResponse,
|
||||
templateRows.every((row) => id(row.id) && id(row.channelId) && id(row.warehouseId) && Array.isArray(row.weekdays)),
|
||||
`count=${templateRows.length}`)
|
||||
|
||||
const exceptionsResponse = await context.request('GET', '/api/platform/v1/sailing-assignments/exceptions?page=1&size=20')
|
||||
const exceptions = context.apiSuccess(module, '查询船期异常分配', exceptionsResponse)
|
||||
const exceptionRows = listOf(exceptions) ?? []
|
||||
context.assert(module, '异常分配列表字段完整', exceptionsResponse,
|
||||
exceptionRows.every((row) => id(row.assignmentId) && Number.isFinite(count(row.volumeCbm))),
|
||||
`count=${exceptionRows.length},total=${exceptions?.total ?? 'array'}`)
|
||||
|
||||
if (records[0]) {
|
||||
const detailResponse = await context.request('GET', `${base}/${encodeURIComponent(records[0].id)}`)
|
||||
const detail = context.apiSuccess(module, '查询当前船期详情', detailResponse)
|
||||
context.assert(module, '船期详情包含版本与货量摘要', detailResponse,
|
||||
id(detail?.sailing?.id) === id(records[0].id)
|
||||
&& Number.isFinite(count(detail?.sailing?.version))
|
||||
&& detail?.sailing?.volumeSummary != null
|
||||
&& Array.isArray(detail?.histories),
|
||||
`id=${detail?.sailing?.id},version=${detail?.sailing?.version}`)
|
||||
} else {
|
||||
context.addCoverageGap(module, 'sailing-calendar', '船期详情与变更历史',
|
||||
'当前月份没有船期记录;需先由订舱/模板生成本轮船期后重跑', 'not-implemented', { blocking: false })
|
||||
}
|
||||
|
||||
const missingResponse = await context.request('GET', `${base}/999999999999999`)
|
||||
context.assert(module, '不存在的船期详情被拒绝', missingResponse,
|
||||
(missingResponse.http === 200 && ['A0403', 'A0404'].includes(String(missingResponse.body?.code)))
|
||||
|| missingResponse.http === 404,
|
||||
`HTTP=${missingResponse.http},code=${missingResponse.body?.code ?? 'missing'}`)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
function count(value) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : NaN
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const module = 'self-operated-analytics'
|
||||
const base = '/api/platform/v1/last-leg/self-operated-analytics'
|
||||
|
||||
const dashboardResponse = await context.request('GET', `${base}/dashboard`)
|
||||
if (dashboardResponse.http === 200 && String(dashboardResponse.body?.code) === '200') {
|
||||
const dashboard = context.apiSuccess(module, '查询自营经营看板', dashboardResponse)
|
||||
context.assert(module, '经营看板六项指标与趋势结构完整', dashboardResponse,
|
||||
dashboard && typeof dashboard === 'object'
|
||||
&& (dashboard.metrics == null || Array.isArray(dashboard.metrics))
|
||||
&& (dashboard.trend == null || Array.isArray(dashboard.trend)),
|
||||
`keys=${Object.keys(dashboard || {}).length}`)
|
||||
} else {
|
||||
context.addCoverageGap(module, 'self-operated-analytics', '经营看板',
|
||||
`需要本轮采集账号与仓库上下文;当前空参请求 HTTP=${dashboardResponse.http}, code=${dashboardResponse.body?.code ?? 'missing'}`,
|
||||
'not-implemented', { blocking: false })
|
||||
}
|
||||
|
||||
const ownersResponse = await context.request('GET', `${base}/owners?page=1&size=20`)
|
||||
if (ownersResponse.http === 200 && String(ownersResponse.body?.code) === '200') {
|
||||
const owners = context.apiSuccess(module, '查询自营货主目录', ownersResponse)
|
||||
context.assert(module, '货主目录分页字段完整', ownersResponse,
|
||||
owners && Array.isArray(owners.records) && Number.isFinite(count(owners.total)),
|
||||
`total=${owners?.total ?? 'missing'}`)
|
||||
} else {
|
||||
context.addCoverageGap(module, 'self-operated-analytics', '货主目录',
|
||||
`需要本轮采集账号上下文;HTTP=${ownersResponse.http}, code=${ownersResponse.body?.code ?? 'missing'}`,
|
||||
'not-implemented', { blocking: false })
|
||||
}
|
||||
|
||||
context.addCoverageGap(module, 'self-operated-analytics', '数据采集任务与重试',
|
||||
'采集任务同步/重试依赖领星外部账号与真实采集批次;本模块只读核对看板与货主目录,缺账号时记录覆盖缺口',
|
||||
'external-third-party', { blocking: false })
|
||||
}
|
||||
Reference in New Issue
Block a user