From e0c03164876158f677f2a5bf80ed5eb32da2b741 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 14:22:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/DispatchBolRepository.java | 17 +++ scripts/admin-test-env-modules/README.md | 11 +- .../appointment-work-contract.test.mjs | 92 ++++++++++++++ .../appointment-work.mjs | 82 +++++++++++++ scripts/admin-test-env-modules/audit.mjs | 36 ++++++ .../fleet-analytics-contract.test.mjs | 90 ++++++++++++++ .../fleet-management.mjs | 79 ++++++++++++ .../pda-audit-sailing-contract.test.mjs | 114 ++++++++++++++++++ .../admin-test-env-modules/pda-releases.mjs | 46 +++++++ .../sailing-calendar.mjs | 80 ++++++++++++ .../self-operated-analytics.mjs | 39 ++++++ 11 files changed, 681 insertions(+), 5 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java create mode 100644 scripts/admin-test-env-modules/appointment-work-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/appointment-work.mjs create mode 100644 scripts/admin-test-env-modules/audit.mjs create mode 100644 scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-management.mjs create mode 100644 scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/pda-releases.mjs create mode 100644 scripts/admin-test-env-modules/sailing-calendar.mjs create mode 100644 scripts/admin-test-env-modules/self-operated-analytics.mjs diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java new file mode 100644 index 000000000..88969a8c8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** BOL专用只读事实入口,应用服务不直接持有Mapper。 */ +@Repository +public class DispatchBolRepository { + private final DispatchBolMapper mapper; + /** 装配同一次事务使用的来源及分配读取。 */ + public DispatchBolRepository(DispatchBolMapper mapper){this.mapper=mapper;} + /** 返回完整明细身份、原调度箱数及逐来源打印资料。 */ + public List cargo(long dispatchId){return mapper.findWholeDispatch(dispatchId);} + /** 仅正式FBA预约分配有外部预约号;客户仓内部DISPATCH批次不伪造约号。 */ + public List allocations(long dispatchId){return mapper.findAppointmentAllocations(dispatchId);} +} diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1df7aa5c5..b8d3b3505 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 入库计划与派送单详情在新建客户无数据时跳过,需先由平台海外链产出单据后定向复核。 ## 本地离线合同运行边界 diff --git a/scripts/admin-test-env-modules/appointment-work-contract.test.mjs b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs new file mode 100644 index 000000000..2a9827277 --- /dev/null +++ b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs @@ -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)) +}) diff --git a/scripts/admin-test-env-modules/appointment-work.mjs b/scripts/admin-test-env-modules/appointment-work.mjs new file mode 100644 index 000000000..321290252 --- /dev/null +++ b/scripts/admin-test-env-modules/appointment-work.mjs @@ -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 }) +} diff --git a/scripts/admin-test-env-modules/audit.mjs b/scripts/admin-test-env-modules/audit.mjs new file mode 100644 index 000000000..66acf24ad --- /dev/null +++ b/scripts/admin-test-env-modules/audit.mjs @@ -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'}`) +} diff --git a/scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs b/scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs new file mode 100644 index 000000000..83405f210 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs @@ -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)) +}) diff --git a/scripts/admin-test-env-modules/fleet-management.mjs b/scripts/admin-test-env-modules/fleet-management.mjs new file mode 100644 index 000000000..76f79e756 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-management.mjs @@ -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 }) +} diff --git a/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs new file mode 100644 index 000000000..01e43a3a8 --- /dev/null +++ b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs @@ -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')) +}) diff --git a/scripts/admin-test-env-modules/pda-releases.mjs b/scripts/admin-test-env-modules/pda-releases.mjs new file mode 100644 index 000000000..ce14a5a95 --- /dev/null +++ b/scripts/admin-test-env-modules/pda-releases.mjs @@ -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 }) +} diff --git a/scripts/admin-test-env-modules/sailing-calendar.mjs b/scripts/admin-test-env-modules/sailing-calendar.mjs new file mode 100644 index 000000000..cc4173f36 --- /dev/null +++ b/scripts/admin-test-env-modules/sailing-calendar.mjs @@ -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'}`) +} diff --git a/scripts/admin-test-env-modules/self-operated-analytics.mjs b/scripts/admin-test-env-modules/self-operated-analytics.mjs new file mode 100644 index 000000000..009d2c330 --- /dev/null +++ b/scripts/admin-test-env-modules/self-operated-analytics.mjs @@ -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 }) +}