优化
This commit is contained in:
+1
-1
@@ -273,7 +273,7 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc
|
||||
|---|---|---|
|
||||
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
|
||||
| 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 |
|
||||
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
|
||||
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据;客户账单、海外仓自助(退货预报写链路与贴标/转运/废弃/库存/箱号只读)、跨境采购/卡车/散板、入库与派送订单、船期价格和报关待办已纳入 |
|
||||
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
|
||||
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'dashboard', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'sailing-calendar', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'fleet-management', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
@@ -96,6 +96,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents', 'phase5-overseas-fulfillment']],
|
||||
['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']],
|
||||
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
|
||||
['appointment-work', ['phase4-carrier-pickup-ready']],
|
||||
['phase4-overseas-pda', ['phase4-tail-order-controls']],
|
||||
['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
|
||||
['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
|
||||
|
||||
@@ -273,7 +273,13 @@ export async function run(context) {
|
||||
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
|
||||
| Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 |
|
||||
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
|
||||
| 预约工作日历与工作事项 | `appointment-work.mjs` | `appointment-work-contract.test.mjs`;默认只读核对月历、汇总、分页、详情与不存在事项拒绝,正向命令待本轮可编辑工作项接入 |
|
||||
| PDA 版本发布 | `pda-releases.mjs` | `pda-audit-sailing-contract.test.mjs`;只读双端发布列表与空上传/不存在版本拒绝,真实 APK/WGT 发布需人工准备安装包 |
|
||||
| 平台操作审计 | `audit.mjs` | `pda-audit-sailing-contract.test.mjs`;分页查询、列表不返回快照、详情与不存在日志拒绝 |
|
||||
| 船期日历 | `sailing-calendar.mjs` | `pda-audit-sailing-contract.test.mjs`;月历、渠道、模板与异常分配只读核对 |
|
||||
| 首页实时指标 | `dashboard.mjs` | `dashboard-contract.test.mjs`;默认只读核对实时柜量/清关指标、今日新运单及启用客户的服务端分页总数 |
|
||||
| 自营经营分析 | `self-operated-analytics.mjs` | `fleet-analytics-contract.test.mjs`;空参探测看板/货主目录,缺采集账号上下文时记录非阻断缺口,不虚构指标 |
|
||||
| 车队车次与出车记录 | `fleet-management.mjs` | `fleet-analytics-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` |
|
||||
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
|
||||
@@ -442,4 +448,37 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
|
||||
|
||||
下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。
|
||||
|
||||
## 新增模块的授权后定向运行清单
|
||||
|
||||
以下模块已注册 `CONFIGURED_MODULES`,离线合同通过;真实 HTTP 仍需当前轮测试环境授权和对应业务前置。默认全量已包含它们,定向排查时按模块名运行,依赖仍由总入口展开。
|
||||
|
||||
| 模块 | 定向命令 | 关键前置 | 报告校验点 |
|
||||
|---|---|---|---|
|
||||
| `appointment-work` | `--modules phase4-carrier-pickup-ready,appointment-work` | 本轮柜、船节点或排车产生工作事项 | 日历/汇总/分页/详情结构;不存在事项命令拒绝 |
|
||||
| `pda-releases` | `--modules pda-releases` | 平台 `platform:pda-release:list` | 双端发布列表结构;空上传与不存在版本拒绝 |
|
||||
| `audit` | `--modules audit` | 平台 `platform:audit:list`,且本轮已有业务写入 | 分页、列表不返回快照、详情与不存在日志拒绝 |
|
||||
| `sailing-calendar` | `--modules sailing-calendar` | 当月已有订舱或模板生成的船期 | 月历、渠道、模板、异常分配;无船期时记录非阻断缺口 |
|
||||
| `self-operated-analytics` | `--modules self-operated-analytics` | 采集账号与仓库上下文 | 空参不可用时记录非阻断缺口,不虚构指标 |
|
||||
| `fleet-management` | `--modules phase4-overseas-master-data,fleet-management` | 本轮车次或排车样本 | 车次、出车记录、报价、拼车需求只读;无车次时记录缺口 |
|
||||
|
||||
Client 全量为单文件,新增的客户账单、海外仓自助、跨境采购/卡车/散板、入库与派送订单、船期价格和报关待办随默认全量执行,不能按模块定向。
|
||||
|
||||
上述模块与 Client 新增端点已按当前 Controller 静态核对存在,包括 `/api/merchant/v1/customs/pending-summary`、`/orders/{orderId}`、`/first-leg/pricing/channels`、`/api/merchant/v1/warehouses`、`/inbound-plans`、`/delivery-orders`、`/api/platform/v1/files/pda-releases` 和 `fleet/operations|quotes|capacity-offers`;真实响应结构仍以 HTTP 验收为准。所需平台权限(`platform:appointment:list`、`platform:pda-release:list`、`platform:audit:list`、`platform:firstleg:sailing-calendar:list`、`platform:self-operated-analytics:list`、`platform:fleet:*:list`)已在 IAM 与服务权限种子中定义并授予 `PLATFORM_ADMIN`,HTTP 403 不应被误判为脚本失败。
|
||||
|
||||
已知待验收缺口(不计作已覆盖通过):
|
||||
|
||||
- `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 链产出的库存;当前只覆盖客户范围列表和退货预报写链路。
|
||||
- `pda-releases` 真实 APK/WGT 上传、发布和停发需人工准备有效安装包。
|
||||
|
||||
## 本地离线合同运行边界
|
||||
|
||||
- 必须在 `baiyue-firstleg` 根运行批量合同:`auth-login-payload` 等合同按 `scripts/...` 相对路径读取,从工作区根运行会产生 `ENOENT` 假失败。
|
||||
- `appointment-work-test-env`、`agents-index-registry`、`ci-maven-resource`、`deploy-built-service-rollback` 会 `spawn` 子进程;受限沙箱下 `EPERM` 属于运行环境限制,不是合同失败。
|
||||
- `database-schema-diff`、`prod-release-*`、`fleet-*-test-env` 等需要数据库或测试环境的合同不进入本地批量扫描;按各自 README 单独授权执行。
|
||||
- 当前 134 个模块合同中有 4 个 `fleet-*` 合同失败(`fleet-cross-customer-fixture`、`fleet-external-dispatch-acceptance`、`fleet-fee-coverage-acceptance`、`fleet-return-redispatch-acceptance`),失败点在 fleet 业务模拟断言,与脚本新增模块和本次 `phase4-overseas-dispatch`/`finance-downloads` 修改无关,另行处理。
|
||||
|
||||
采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。
|
||||
|
||||
@@ -65,3 +65,9 @@ test('未部署时能力探测后记录部署前置,不借历史业务数据',
|
||||
assert.match(gaps[0][3], /部署后重跑完整链/)
|
||||
assert.equal(steps.some((step) => step.name === '读取客户账户筛选事实'), false)
|
||||
})
|
||||
|
||||
test('同参导出只合并进行中任务,已完成任务允许按同参重新导出', () => {
|
||||
assert.match(source, /60 秒同参请求合并/)
|
||||
assert.match(source, /String\(originalStatus\) === 'SUCCEEDED'/)
|
||||
assert.match(source, /File 只合并 PENDING\/RUNNING 的同参任务/)
|
||||
})
|
||||
|
||||
@@ -127,8 +127,13 @@ async function createTask(context, type, parameters, suffix, expectedHeaders, ex
|
||||
const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body,
|
||||
context.token, { 'Idempotency-Key': idempotencyKey })
|
||||
const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, id(duplicate?.id) === taskId,
|
||||
`original=${taskId}, duplicate=${id(duplicate?.id) || 'missing'}`, OPTIONS)
|
||||
const originalRead = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`)
|
||||
const originalStatus = originalRead.body?.data?.status
|
||||
context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse,
|
||||
// File 只合并 PENDING/RUNNING 的同参任务;已完成任务允许按同参重新导出。
|
||||
id(duplicate?.id) === taskId || String(originalStatus) === 'SUCCEEDED',
|
||||
`original=${taskId}, originalStatus=${originalStatus || 'missing'}, duplicate=${id(duplicate?.id) || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const listResponse = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=100&exportType=${type}`)
|
||||
const page = context.apiSuccess(MODULE, `${type} 下载中心列表可见`, listResponse, '', OPTIONS)
|
||||
|
||||
@@ -101,6 +101,8 @@ export function markerCandidates(context, candidates) {
|
||||
}
|
||||
export function notRun(context, proof, reason) {
|
||||
proof.status = 'NOT_RUN'; proof.reason = reason
|
||||
// 未运行时保留既有终态;否则显式标记 NOT_RUN,便于下游区分前置不足与代码失败。
|
||||
proof.finalStatus ??= 'NOT_RUN'
|
||||
context.addCoverageGap?.('phase4-overseas-dispatch', 'native-dispatch', '缺少当前生产前置', reason, 'fixture', { deploymentBlocker: true })
|
||||
return proof
|
||||
}
|
||||
|
||||
@@ -706,7 +706,7 @@ test('container overview always follows phase3 loading in default and custom mod
|
||||
const entry = source('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
|
||||
assert.match(entry, /const modules = normalizeModuleOrder\(/)
|
||||
assert.match(entry, /return \{[\s\S]{0,500}modules,/)
|
||||
assert.match(entry, /return \{[\s\S]{0,1200}modules,/)
|
||||
})
|
||||
|
||||
test('overview does not fall back to a container fixture when loading evidence was requested', () => {
|
||||
|
||||
@@ -311,3 +311,11 @@ test('platform scope zero completes the same detail and manual-node lifecycle as
|
||||
assert.ok(fixture.assertions.every((check) => check.passed))
|
||||
assert.ok(fixture.steps.every((check) => check.passed))
|
||||
})
|
||||
|
||||
test('还柜终态拆分为节点覆盖与停止语义,人工节点下限扣除出仓自动节点和拖车回写节点', () => {
|
||||
assert.match(source, /拖车业务回写后覆盖十标准实际节点/)
|
||||
assert.match(source, /还柜终态停止语义与本轮货柜关联/)
|
||||
assert.match(source, /STANDARD_NODES\.length - 2/)
|
||||
assert.match(source, /stopReason=\$\{finalTask\.stopReason/)
|
||||
assert.doesNotMatch(source, /覆盖十标准实际节点且EMPTY_RETURNED停止/)
|
||||
})
|
||||
|
||||
@@ -348,16 +348,23 @@ export async function run(context) {
|
||||
// PICKED_UP; the immutable carrier history therefore contains the eight
|
||||
// pre-pickup nodes plus EMPTY_RETURNED.
|
||||
const carrierNodesWithoutInferredPickup = STANDARD_NODES.filter((node) => node !== 'PICKED_UP')
|
||||
context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse,
|
||||
carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED'
|
||||
&& finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜'
|
||||
context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点', detailResponse,
|
||||
carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)),
|
||||
`actualNodes=${[...actualNodes].join(',')}, missing=${carrierNodesWithoutInferredPickup.filter((node) => !actualNodes.has(node)).join(',')}`,
|
||||
OPTIONS)
|
||||
context.assert(MODULE, '还柜终态停止语义与本轮货柜关联', detailResponse,
|
||||
finalTask.status === 'STOPPED' && finalTask.latestActualNode === 'EMPTY_RETURNED'
|
||||
&& finalTask.stopReason === '已还柜'
|
||||
&& id(finalTask.containerBookingId) === id(drayage.containerBookingId),
|
||||
`actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS)
|
||||
`status=${finalTask.status}, latest=${finalTask.latestActualNode || 'none'}, `
|
||||
+ `stopReason=${finalTask.stopReason || 'none'}, taskBooking=${id(finalTask.containerBookingId)}, `
|
||||
+ `drayageBooking=${id(drayage.containerBookingId)}`, OPTIONS)
|
||||
|
||||
const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`)
|
||||
const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse,
|
||||
Array.isArray(history) && history.every(validHistory)
|
||||
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 1,
|
||||
// 首个标准节点由国内出仓自动写入,PICKED_UP 由码头拖车业务回写,人工补录最多 8 条。
|
||||
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 2,
|
||||
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ $requiredTests = @(
|
||||
"firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java",
|
||||
"firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java",
|
||||
"warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java",
|
||||
"warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java"
|
||||
"warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java",
|
||||
"lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkContractTest.java",
|
||||
"audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java",
|
||||
"file/src/test/java/com/baiyue/file/service/PdaReleaseServiceTest.java",
|
||||
"firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/controller/SailingCalendarOpenApiContractTest.java",
|
||||
"lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java",
|
||||
"lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java"
|
||||
)
|
||||
$mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', "-f", $pom, "test")
|
||||
|
||||
|
||||
@@ -504,3 +504,77 @@ test('F04 still rejects a numeric error row pointing to the successful row', asy
|
||||
const { r } = importHarness('wrong-error-row')
|
||||
await assert.rejects(() => r.orderExcelImportModule(), /actual Excel error row/)
|
||||
})
|
||||
|
||||
test('F01 client bills module covers customer-only read APIs and rejects unknown documents', async () => {
|
||||
const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /async function merchantFinanceBillsModule\(\)/)
|
||||
assert.match(source, /await merchantFinanceBillsModule\(\)/)
|
||||
const callOrder = [
|
||||
source.indexOf('await orderSubmissionChannelGateModule()'),
|
||||
source.indexOf('await merchantFinanceBillsModule()'),
|
||||
source.indexOf('await notificationInboxModule()'),
|
||||
]
|
||||
assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1],
|
||||
'bills module must run after channel gate and before notifications')
|
||||
assert.match(source, /const base = '\/api\/client\/finance\/v1'/)
|
||||
for (const path of ['/bills', '/statements', '/receipts', '/receivable-settlements', '/confirm']) {
|
||||
assert.ok(source.includes(path), path)
|
||||
}
|
||||
assert.match(source, /customerId=0&tenantId=0/)
|
||||
assert.match(source, /customer bills ignore caller-supplied customer scope/)
|
||||
assert.match(source, /cross-customer bill detail stays outside the other enterprise/)
|
||||
assert.match(source, /unknown statement confirmation is rejected with idempotency key/)
|
||||
assert.match(source, /customer receipts are confirmed-only and customer-scoped/)
|
||||
assert.doesNotMatch(source, /jdbc|INSERT INTO/i)
|
||||
})
|
||||
|
||||
test('F02 client overseas self-service covers customer-scoped lists and the return forecast write chain', async () => {
|
||||
const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /async function merchantOverseasWarehouseModule\(\)/)
|
||||
assert.match(source, /await merchantOverseasWarehouseModule\(\)/)
|
||||
const callOrder = [
|
||||
source.indexOf('await merchantFinanceBillsModule()'),
|
||||
source.indexOf('await merchantOverseasWarehouseModule()'),
|
||||
source.indexOf('await notificationInboxModule()'),
|
||||
]
|
||||
assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1],
|
||||
'overseas self-service must run after bills and before notifications')
|
||||
for (const path of ['/api/merchant/v1/warehouses', '/return-orders', '/relabel-applications',
|
||||
'/warehouse-transfers', '/cargo-discards', '/return-inventory', '/return-inventory/summary',
|
||||
'/return-inventory/cartons']) {
|
||||
assert.ok(source.includes(path), path)
|
||||
}
|
||||
assert.match(source, /create return forecast with marker-owned payload/)
|
||||
assert.match(source, /same idempotency key replays the same return order/)
|
||||
assert.match(source, /return order appears in the customer list/)
|
||||
assert.match(source, /cross-customer return order is rejected/)
|
||||
assert.match(source, /overseasWarehouseReadOnlyGaps/)
|
||||
assert.doesNotMatch(source, /jdbc|INSERT INTO/i)
|
||||
})
|
||||
|
||||
test('F03 client cross-border, warehouse orders and pricing/customs cover current customer pages', async () => {
|
||||
const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
for (const name of ['merchantCrossBorderModule', 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule']) {
|
||||
assert.match(source, new RegExp(`async function ${name}\\(\\)`), name)
|
||||
assert.match(source, new RegExp(`await ${name}\\(\\)`), name)
|
||||
}
|
||||
const order = ['merchantOverseasWarehouseModule', 'merchantCrossBorderModule',
|
||||
'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule', 'notificationInboxModule']
|
||||
.map((name) => source.indexOf(`await ${name}()`))
|
||||
assert.ok(order.every((index) => index >= 0) && order.every((index, position) => position === 0 || index > order[position - 1]),
|
||||
'new client modules must run in order before notifications')
|
||||
for (const path of ['/us-truck-orders', '/local-looseboard-orders', '/overseas-procurements',
|
||||
'/inbound-plans', '/delivery-orders', '/first-leg/pricing/channels', '/customs/pending-summary',
|
||||
'/customs/orders/']) {
|
||||
assert.ok(source.includes(path), path)
|
||||
}
|
||||
assert.match(source, /same idempotency key replays the same US truck order/)
|
||||
assert.match(source, /same idempotency key replays the same looseboard order/)
|
||||
assert.match(source, /same idempotency key replays the same procurement order/)
|
||||
assert.match(source, /customer inbound plans are customer-scoped/)
|
||||
assert.match(source, /customer delivery orders are customer-scoped/)
|
||||
assert.match(source, /customer customs pending summary is readable/)
|
||||
assert.match(source, /crossBorderLimitations/)
|
||||
assert.doesNotMatch(source, /jdbc|INSERT INTO/i)
|
||||
})
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ try {
|
||||
await orderMutationEvidenceModule()
|
||||
await orderExcelImportModule()
|
||||
await orderSubmissionChannelGateModule()
|
||||
await merchantFinanceBillsModule()
|
||||
await merchantOverseasWarehouseModule()
|
||||
await merchantCrossBorderModule()
|
||||
await merchantWarehouseOrdersModule()
|
||||
await merchantPricingCustomsModule()
|
||||
await notificationInboxModule()
|
||||
await accountSecurityModule()
|
||||
report.success = report.steps.every((step) => step.passed)
|
||||
@@ -1490,6 +1495,520 @@ async function isolationModule() {
|
||||
}, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code }))
|
||||
}
|
||||
|
||||
async function merchantFinanceBillsModule() {
|
||||
const module = 'merchant-bills'
|
||||
const base = '/api/client/finance/v1'
|
||||
const customerId = String(report.resources.tenantId)
|
||||
const requireArray = (json, label) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data), `${label} must be an array`)
|
||||
return json.data
|
||||
}
|
||||
const bills = await apiStep(module, 'customer bills ignore caller-supplied customer scope', {
|
||||
path: `${base}/bills?customerId=0&tenantId=0`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
const rows = requireArray(json, 'bills')
|
||||
for (const row of rows) {
|
||||
assertEqual(String(row.customerId), customerId, 'bill owner must be the authenticated customer')
|
||||
assertPresent(row.billNo, 'billNo')
|
||||
assertPresent(row.customerName, 'customerName')
|
||||
assertTrue(['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(row.status), `unexpected visible bill status ${row.status}`)
|
||||
assertTrue(Number.isSafeInteger(Number(row.version)), 'bill version required')
|
||||
assertTrue(['NOT_REQUIRED', 'PENDING_CONFIRMATION', 'CONFIRMED'].includes(row.customerConfirmationStatus),
|
||||
'customer confirmation status required')
|
||||
}
|
||||
}, ({ json }) => ({ customerId, count: json.data?.length ?? null, scope: 'authenticated-enterprise-only' }))
|
||||
const statements = await apiStep(module, 'customer statements expose the same visible bills', {
|
||||
path: `${base}/statements`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
const rows = requireArray(json, 'statements')
|
||||
assertEqual(rows.length, bills.json.data.length, 'bills and statements must expose the same visible set')
|
||||
for (const row of rows) assertEqual(String(row.customerId), customerId, 'statement owner must be the authenticated customer')
|
||||
}, ({ json }) => ({ count: json.data?.length ?? null }))
|
||||
await apiStep(module, 'customer receipts are confirmed-only and customer-scoped', {
|
||||
path: `${base}/receipts`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
const rows = requireArray(json, 'receipts')
|
||||
for (const row of rows) {
|
||||
assertPresent(row.receiptNo, 'receiptNo')
|
||||
assertTrue(Number.isFinite(Number(row.receivedAmount)), 'receipt amount required')
|
||||
assertEqual(row.status, 'CONFIRMED', 'only confirmed receipts are customer-visible')
|
||||
}
|
||||
}, ({ json }) => ({ count: json.data?.length ?? null }))
|
||||
await apiStep(module, 'customer receivable settlements are readable', {
|
||||
path: `${base}/receivable-settlements`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
const rows = requireArray(json, 'receivable settlements')
|
||||
for (const row of rows) {
|
||||
assertPresent(row.settlementNo, 'settlementNo')
|
||||
assertEqual(String(row.customerId), customerId, 'settlement owner must be the authenticated customer')
|
||||
assertTrue(Number.isFinite(Number(row.receivableAmount)), 'receivable amount required')
|
||||
}
|
||||
}, ({ json }) => ({ count: json.data?.length ?? null }))
|
||||
await apiStep(module, 'unknown bill id is rejected without cross-customer disclosure', {
|
||||
path: `${base}/bills/999999999999999`, token: merchantToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', `unknown bill must not succeed, code=${json?.code}`)
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
await apiStep(module, 'unknown statement confirmation is rejected with idempotency key', {
|
||||
method: 'POST', path: `${base}/statements/999999999999999/confirm`, token: merchantToken,
|
||||
headers: { 'Idempotency-Key': newIdempotencyKey('bill-confirm-negative') },
|
||||
body: { expectedVersion: 1 },
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', `unknown statement must not confirm, code=${json?.code}`)
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
if (existingClientToken) {
|
||||
await apiStep(module, 'cross-customer bill detail stays outside the other enterprise', {
|
||||
path: `${base}/bills/999999999999999`, token: existingClientToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', 'another enterprise bill must not be readable')
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
} else {
|
||||
recordSkipped(module, 'cross-customer bill detail stays outside the other enterprise',
|
||||
'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check')
|
||||
}
|
||||
report.merchantFinanceBills = {
|
||||
customerId,
|
||||
bills: bills.json.data.length,
|
||||
statements: statements.json.data.length,
|
||||
scope: 'read-only; statement confirmation with real issued bills is covered when Admin finance chain runs first',
|
||||
}
|
||||
}
|
||||
|
||||
async function merchantOverseasWarehouseModule() {
|
||||
const module = 'merchant-overseas-warehouse'
|
||||
const base = '/api/merchant/v1/last-leg'
|
||||
const page = 'page=1&size=20'
|
||||
const listStep = async (label, path, validateRow) => {
|
||||
const response = await apiStep(module, label, { path, token: merchantToken }, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`)
|
||||
assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`)
|
||||
for (const row of json.data.records) validateRow?.(row)
|
||||
}, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null }))
|
||||
return response.json.data
|
||||
}
|
||||
|
||||
const warehouseResponse = await apiStep(module, 'customer reads active overseas warehouses', {
|
||||
path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS', token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'at least one active overseas warehouse required')
|
||||
assertTrue(json.data.records.every((row) => row.status === 'ACTIVE' && row.warehouseType === 'OVERSEAS'),
|
||||
'customer warehouse list must contain only active overseas warehouses')
|
||||
}, ({ json }) => ({ count: json.data?.records?.length ?? null }))
|
||||
const warehouse = warehouseResponse.json.data.records[0]
|
||||
|
||||
await listStep('return forecasts are customer-scoped', `${base}/return-orders?${page}`, (row) => {
|
||||
assertPresent(row.returnNo, 'returnNo')
|
||||
assertPresent(row.status, 'return status')
|
||||
})
|
||||
await listStep('relabel applications are customer-scoped', `${base}/relabel-applications?${page}`, (row) => {
|
||||
assertPresent(row.relabelNo, 'relabelNo')
|
||||
assertTrue(Number.isFinite(Number(row.totalPackageCount)), 'relabel package count required')
|
||||
})
|
||||
await listStep('warehouse transfers are customer-scoped', `${base}/warehouse-transfers?${page}`, (row) => {
|
||||
assertPresent(row.transferNo, 'transferNo')
|
||||
assertPresent(row.status, 'transfer status')
|
||||
})
|
||||
await listStep('cargo discards are customer-scoped', `${base}/cargo-discards?${page}`, (row) => {
|
||||
assertPresent(row.discardNo, 'discardNo')
|
||||
assertTrue(Number.isFinite(Number(row.totalPackages)), 'discard package count required')
|
||||
})
|
||||
await listStep('return inventory is customer-scoped', `${base}/return-inventory?${page}`, (row) => {
|
||||
assertPresent(row.cartonMark, 'cartonMark')
|
||||
assertTrue(Number.isFinite(Number(row.quantity)), 'return inventory quantity required')
|
||||
})
|
||||
await listStep('return inventory summary is customer-scoped', `${base}/return-inventory/summary?${page}`, (row) => {
|
||||
assertPresent(row.skuCode, 'summary skuCode')
|
||||
assertTrue(Number.isFinite(Number(row.availableQuantity)), 'available quantity required')
|
||||
})
|
||||
await listStep('return cartons are customer-scoped', `${base}/return-inventory/cartons?${page}`, (row) => {
|
||||
assertPresent(row.cartonMark, 'cartonMark')
|
||||
assertPresent(row.status, 'carton status')
|
||||
})
|
||||
|
||||
const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-20)
|
||||
const sku = `ACCEPT-${suffix}`
|
||||
const createPayload = {
|
||||
overseasWarehouseId: String(warehouse.id),
|
||||
warehouseContactName: '验收联系人',
|
||||
warehouseContactPhone: '13800000000',
|
||||
customerReferenceNo: `RET-${suffix}`,
|
||||
needRelabel: false,
|
||||
remark: `${marker} client overseas self-service acceptance`,
|
||||
idempotencyKey: newIdempotencyKey('return-order'),
|
||||
items: [{ skuCode: sku, productNameZh: '验收测试货物', expectedQuantity: 1 }],
|
||||
}
|
||||
const created = await apiStep(module, 'create return forecast with marker-owned payload', {
|
||||
method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertPresent(json.data?.order?.returnNo, 'returnNo')
|
||||
assertEqual(String(json.data.order.overseasWarehouseId), String(warehouse.id), 'return order warehouse')
|
||||
assertTrue(Array.isArray(json.data.items) && json.data.items.length === 1
|
||||
&& json.data.items[0].skuCode === sku && Number(json.data.items[0].expectedQuantity) === 1,
|
||||
'return order items echo the marker payload')
|
||||
}, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, status: json.data?.order?.status ?? null }))
|
||||
const returnNo = created.json.data.order.returnNo
|
||||
report.resources.returnOrderNos = [returnNo]
|
||||
|
||||
await apiStep(module, 'same idempotency key replays the same return order', {
|
||||
method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.order?.returnNo, returnNo, 'replayed returnNo must match the original')
|
||||
}, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null }))
|
||||
|
||||
await apiStep(module, 'return order detail keeps the marker payload', {
|
||||
path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.order?.returnNo, returnNo, 'detail returnNo')
|
||||
assertTrue(Array.isArray(json.data.items) && json.data.items.some((item) => item.skuCode === sku),
|
||||
'detail keeps the submitted sku')
|
||||
}, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, items: json.data?.items?.length ?? null }))
|
||||
|
||||
await apiStep(module, 'return order appears in the customer list', {
|
||||
path: `${base}/return-orders?${page}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(json.data.records.some((row) => String(row.returnNo) === String(returnNo)),
|
||||
'new return order must appear in the first customer page')
|
||||
}, ({ json }) => ({ total: json.data?.total ?? null, returnNo }))
|
||||
|
||||
await apiStep(module, 'unknown return order is rejected', {
|
||||
path: `${base}/return-orders/${encodeURIComponent(`RET-MISSING-${suffix}`)}`, token: merchantToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', `unknown return order must not succeed, code=${json?.code}`)
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
|
||||
if (existingClientToken) {
|
||||
await apiStep(module, 'cross-customer return order is rejected', {
|
||||
path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: existingClientToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', 'another enterprise return order must not be readable')
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
} else {
|
||||
recordSkipped(module, 'cross-customer return order is rejected',
|
||||
'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check')
|
||||
}
|
||||
|
||||
report.overseasWarehouseReadOnlyGaps = [
|
||||
'Relabel, transfer and cargo-discard creation require inventory produced by the platform overseas PDA chain; this run only covers their customer-scoped lists and the return forecast write chain.',
|
||||
'Return relabel creation, completion confirmation and document downloads require received return cartons produced by the PDA return receipt chain.',
|
||||
]
|
||||
}
|
||||
|
||||
async function merchantCrossBorderModule() {
|
||||
const module = 'merchant-cross-border'
|
||||
const truckPath = '/api/merchant/v1/last-leg/us-truck-orders'
|
||||
const looseboardPath = '/api/merchant/v1/last-leg/local-looseboard-orders'
|
||||
const procurementPath = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const page = 'page=1&size=20'
|
||||
const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-18)
|
||||
const localTime = (days) => {
|
||||
const date = new Date(Date.now() + days * 86_400_000)
|
||||
return date.toISOString().slice(0, 19)
|
||||
}
|
||||
const listStep = async (label, path, validateRow) => apiStep(module, label, { path, token: merchantToken }, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`)
|
||||
assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`)
|
||||
for (const row of json.data.records) validateRow?.(row)
|
||||
}, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null }))
|
||||
|
||||
await listStep('customer procurement orders are customer-scoped', `${procurementPath}?${page}`, (row) => {
|
||||
assertPresent(row.orderNo, 'procurement orderNo')
|
||||
assertPresent(row.status, 'procurement status')
|
||||
})
|
||||
await listStep('customer US truck orders are customer-scoped', `${truckPath}?${page}`, (row) => {
|
||||
assertPresent(row.orderNo, 'truck orderNo')
|
||||
assertPresent(row.status, 'truck status')
|
||||
})
|
||||
await listStep('customer local looseboard orders are customer-scoped', `${looseboardPath}?${page}`, (row) => {
|
||||
assertPresent(row.orderNo, 'looseboard orderNo')
|
||||
assertPresent(row.status, 'looseboard status')
|
||||
})
|
||||
|
||||
const truckPayload = {
|
||||
vehicleType: 'PALLET_15',
|
||||
origin: `验收起点-${suffix}`,
|
||||
destination: `验收终点-${suffix}`,
|
||||
packageCount: 2,
|
||||
totalWeightKg: 12.5,
|
||||
totalVolumeCbm: 0.8,
|
||||
budgetAmount: 100,
|
||||
currency: 'USD',
|
||||
originContactName: '验收发货联系人',
|
||||
originContactPhone: '13800000001',
|
||||
destinationContactName: '验收收货联系人',
|
||||
destinationContactPhone: '13800000002',
|
||||
pickupAt: localTime(2),
|
||||
deliveryAt: localTime(3),
|
||||
remark: `${marker} client US truck acceptance`,
|
||||
idempotencyKey: newIdempotencyKey('us-truck'),
|
||||
}
|
||||
const truck = await apiStep(module, 'customer creates a US truck order', {
|
||||
method: 'POST', path: truckPath, token: merchantToken, body: truckPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertPresent(json.data?.summary?.orderNo, 'truck orderNo')
|
||||
assertTrue(['PENDING_REVIEW', 'SUBMITTED', 'DRAFT'].includes(json.data.summary.status),
|
||||
`unexpected truck status ${json.data.summary.status}`)
|
||||
assertTrue(Number(json.data.summary.version) >= 0, 'truck version required')
|
||||
}, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null }))
|
||||
const truckNo = truck.json.data.summary.orderNo
|
||||
report.resources.crossBorderOrderNos = [truckNo]
|
||||
|
||||
await apiStep(module, 'same idempotency key replays the same US truck order', {
|
||||
method: 'POST', path: truckPath, token: merchantToken, body: truckPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.summary?.orderNo, truckNo, 'replayed truck orderNo')
|
||||
}, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null }))
|
||||
|
||||
await apiStep(module, 'US truck order detail keeps the marker payload', {
|
||||
path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.summary?.orderNo, truckNo, 'truck detail orderNo')
|
||||
assertEqual(json.data?.summary?.vehicleType, 'PALLET_15', 'truck vehicle type')
|
||||
assertEqual(Number(json.data?.summary?.packageCount), 2, 'truck package count')
|
||||
}, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null }))
|
||||
|
||||
const looseboardPayload = {
|
||||
origin: `散板起点-${suffix}`,
|
||||
destination: `散板终点-${suffix}`,
|
||||
productName: '验收散板货物',
|
||||
estimatedPalletCount: 1,
|
||||
packageCount: 3,
|
||||
totalWeightKg: 20,
|
||||
totalVolumeCbm: 1.2,
|
||||
budgetAmount: 80,
|
||||
currency: 'USD',
|
||||
originContactName: '验收发货联系人',
|
||||
originContactPhone: '13800000001',
|
||||
destinationContactName: '验收收货联系人',
|
||||
destinationContactPhone: '13800000002',
|
||||
pickupAt: localTime(2),
|
||||
deliveryAt: localTime(4),
|
||||
remark: `${marker} client looseboard acceptance`,
|
||||
idempotencyKey: newIdempotencyKey('looseboard'),
|
||||
}
|
||||
const looseboard = await apiStep(module, 'customer creates a local looseboard order', {
|
||||
method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertPresent(json.data?.summary?.orderNo, 'looseboard orderNo')
|
||||
assertTrue(Number(json.data.summary.estimatedPalletCount) === 1, 'looseboard pallet count')
|
||||
}, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null }))
|
||||
const looseboardNo = looseboard.json.data.summary.orderNo
|
||||
report.resources.crossBorderOrderNos.push(looseboardNo)
|
||||
|
||||
await apiStep(module, 'same idempotency key replays the same looseboard order', {
|
||||
method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.summary?.orderNo, looseboardNo, 'replayed looseboard orderNo')
|
||||
}, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null }))
|
||||
|
||||
const procurementPayload = {
|
||||
customerName: companyName,
|
||||
purchaseCategory: '验收测试',
|
||||
productName: `验收采购样品-${suffix}`,
|
||||
productSpecification: '1 件/箱',
|
||||
purchaseQuantity: 1,
|
||||
budgetUnitPrice: 10,
|
||||
currency: 'CNY',
|
||||
receivingAddress: '广东省深圳市南山区验收路 1 号',
|
||||
contactName: '验收采购联系人',
|
||||
contactPhone: '13800000003',
|
||||
remark: `${marker} client procurement acceptance`,
|
||||
idempotencyKey: newIdempotencyKey('procurement'),
|
||||
}
|
||||
const procurement = await apiStep(module, 'customer creates an overseas procurement order', {
|
||||
method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertPresent(json.data?.order?.orderNo, 'procurement orderNo')
|
||||
assertTrue(Number(json.data.order.purchaseQuantity) === 1, 'procurement quantity')
|
||||
}, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null, status: json.data?.order?.status ?? null }))
|
||||
const procurementNo = procurement.json.data.order.orderNo
|
||||
report.resources.crossBorderOrderNos.push(procurementNo)
|
||||
|
||||
await apiStep(module, 'same idempotency key replays the same procurement order', {
|
||||
method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.order?.orderNo, procurementNo, 'replayed procurement orderNo')
|
||||
}, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null }))
|
||||
|
||||
await apiStep(module, 'created cross-border orders appear in customer lists', {
|
||||
path: `${truckPath}?${page}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(json.data.records.some((row) => String(row.orderNo) === String(truckNo)),
|
||||
'new truck order must appear in the customer list')
|
||||
}, ({ json }) => ({ total: json.data?.total ?? null, truckNo }))
|
||||
for (const [label, path] of [['truck', truckPath], ['looseboard', looseboardPath], ['procurement', procurementPath]]) {
|
||||
await apiStep(module, `unknown ${label} order is rejected`, {
|
||||
path: `${path}/${encodeURIComponent(`CB-MISSING-${suffix}`)}`, token: merchantToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', `unknown ${label} order must not succeed, code=${json?.code}`)
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
}
|
||||
|
||||
if (existingClientToken) {
|
||||
await apiStep(module, 'cross-customer truck order is rejected', {
|
||||
path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: existingClientToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', 'another enterprise truck order must not be readable')
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
} else {
|
||||
recordSkipped(module, 'cross-customer truck order is rejected',
|
||||
'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check')
|
||||
}
|
||||
|
||||
report.crossBorderLimitations = [
|
||||
'Customer creation stops at the submitted/pending platform review state; platform review, pricing, purchase execution, receipts and delivery are covered by Admin/fleet specialty entries.',
|
||||
]
|
||||
}
|
||||
|
||||
async function merchantWarehouseOrdersModule() {
|
||||
const module = 'merchant-warehouse-orders'
|
||||
const base = '/api/merchant/v1/last-leg'
|
||||
const page = 'page=1&size=20'
|
||||
const inbound = await apiStep(module, 'customer inbound plans are customer-scoped', {
|
||||
path: `${base}/inbound-plans?${page}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)),
|
||||
'inbound plan page required')
|
||||
for (const row of json.data.records) {
|
||||
assertPresent(row.planNo, 'planNo')
|
||||
assertPresent(row.inboundCode, 'inboundCode')
|
||||
assertTrue(Number.isFinite(Number(row.packageCount)), 'inbound packageCount')
|
||||
assertPresent(row.status, 'inbound status')
|
||||
}
|
||||
}, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null }))
|
||||
|
||||
const delivery = await apiStep(module, 'customer delivery orders are customer-scoped', {
|
||||
path: `${base}/delivery-orders?${page}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)),
|
||||
'delivery page required')
|
||||
for (const row of json.data.records) {
|
||||
assertPresent(row.deliveryNo, 'deliveryNo')
|
||||
assertPresent(row.dispatchNo, 'dispatchNo')
|
||||
assertPresent(row.status, 'delivery status')
|
||||
assertTrue(Number.isFinite(Number(row.packageCount)), 'delivery packageCount')
|
||||
}
|
||||
}, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null }))
|
||||
|
||||
const planNo = inbound.json.data.records[0]?.planNo
|
||||
if (planNo) {
|
||||
await apiStep(module, 'inbound plan detail keeps plan identity and lines', {
|
||||
path: `${base}/inbound-plans/${encodeURIComponent(planNo)}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.planNo, planNo, 'planNo')
|
||||
assertPresent(json.data?.status, 'plan status')
|
||||
assertTrue(Array.isArray(json.data?.lines) && Array.isArray(json.data?.pallets), 'plan lines and pallets required')
|
||||
}, ({ json }) => ({ planNo: json.data?.planNo ?? null, lines: json.data?.lines?.length ?? null }))
|
||||
} else {
|
||||
recordSkipped(module, 'inbound plan detail keeps plan identity and lines',
|
||||
'this run customer has no inbound plan yet')
|
||||
}
|
||||
|
||||
const deliveryNo = delivery.json.data.records[0]?.deliveryNo
|
||||
if (deliveryNo) {
|
||||
await apiStep(module, 'delivery order detail keeps customer-visible fees and skus', {
|
||||
path: `${base}/delivery-orders/${encodeURIComponent(deliveryNo)}?page=1&size=200`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.order?.deliveryNo, deliveryNo, 'deliveryNo')
|
||||
assertTrue(Array.isArray(json.data?.skus) && Array.isArray(json.data?.fees), 'delivery skus and fees required')
|
||||
assertPresent(json.data?.order?.status, 'delivery status')
|
||||
}, ({ json }) => ({ deliveryNo: json.data?.order?.deliveryNo ?? null, skus: json.data?.skus?.length ?? null }))
|
||||
} else {
|
||||
recordSkipped(module, 'delivery order detail keeps customer-visible fees and skus',
|
||||
'this run customer has no delivery order yet')
|
||||
}
|
||||
|
||||
for (const [label, path] of [['inbound plan', `${base}/inbound-plans`], ['delivery order', `${base}/delivery-orders`]]) {
|
||||
await apiStep(module, `unknown ${label} is rejected`, {
|
||||
path: `${path}/${encodeURIComponent(`WO-MISSING-${marker}`)}`, token: merchantToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
assertTrue(String(json?.code) !== '200', `unknown ${label} must not succeed, code=${json?.code}`)
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null }))
|
||||
}
|
||||
}
|
||||
|
||||
async function merchantPricingCustomsModule() {
|
||||
const module = 'merchant-pricing-customs'
|
||||
const warehouseResponse = await apiStep(module, 'customer reads active warehouses for pricing', {
|
||||
path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE', token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'active warehouse required')
|
||||
assertTrue(json.data.records.every((row) => row.status === 'ACTIVE'), 'only active warehouses allowed')
|
||||
}, ({ json }) => ({ count: json.data?.records?.length ?? null }))
|
||||
const domestic = warehouseResponse.json.data.records.find((row) => row.warehouseType !== 'OVERSEAS')
|
||||
if (domestic) {
|
||||
await apiStep(module, 'customer reads active pricing channels', {
|
||||
path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(String(domestic.id))}`,
|
||||
token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data), 'pricing channels must be an array')
|
||||
for (const row of json.data) {
|
||||
assertPresent(row.channelCode, 'channelCode')
|
||||
assertPresent(row.priceModel, 'priceModel')
|
||||
assertTrue(Array.isArray(row.pricingTaxModes), 'pricingTaxModes required')
|
||||
assertTrue(Number.isFinite(Number(row.priceBookVersionId)), 'priceBookVersionId required')
|
||||
}
|
||||
}, ({ json }) => ({ count: json.data?.length ?? null, warehouseId: String(domestic.id) }))
|
||||
} else {
|
||||
recordSkipped(module, 'customer reads active pricing channels',
|
||||
'this customer has no domestic origin warehouse')
|
||||
}
|
||||
|
||||
await apiStep(module, 'customer customs pending summary is readable', {
|
||||
path: '/api/merchant/v1/customs/pending-summary', token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Number.isFinite(Number(json.data?.pendingCount)), 'pendingCount required')
|
||||
assertTrue(Number(json.data.pendingCount) >= 0, 'pendingCount must be non-negative')
|
||||
}, ({ json }) => ({ pendingCount: json.data?.pendingCount ?? null }))
|
||||
|
||||
const orderId = report.resources.orderIds[0]
|
||||
if (orderId) {
|
||||
await apiStep(module, 'order customs documents stay customer-scoped', {
|
||||
path: `/api/merchant/v1/customs/orders/${encodeURIComponent(String(orderId))}`, token: merchantToken,
|
||||
}, ({ json, status }) => {
|
||||
assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`)
|
||||
if (String(json?.code) === '200') {
|
||||
assertTrue(json.data && typeof json.data === 'object', 'customs payload required')
|
||||
} else {
|
||||
assertTrue(['A0403', 'A0404'].includes(String(json?.code)),
|
||||
`unexpected customs code ${json?.code}`)
|
||||
}
|
||||
}, ({ json, status }) => ({ http: status, code: json?.code ?? null, orderId: String(orderId) }))
|
||||
} else {
|
||||
recordSkipped(module, 'order customs documents stay customer-scoped', 'no current-run order id')
|
||||
}
|
||||
}
|
||||
|
||||
async function notificationInboxModule() {
|
||||
const module = 'notification-inbox'
|
||||
const templateBase = '/api/platform/v1/notification-templates'
|
||||
|
||||
@@ -190,3 +190,13 @@ test('Finance-only HTTP entry expands producer prerequisites and runs downloads
|
||||
'finance-direct-paid-isolation', 'finance-downloads',
|
||||
])
|
||||
})
|
||||
|
||||
test('default Admin HTTP entry registers current-code appointment, PDA release, audit and sailing calendar modules', () => {
|
||||
for (const module of ['appointment-work', 'pda-releases', 'audit', 'sailing-calendar',
|
||||
'self-operated-analytics', 'fleet-management']) {
|
||||
assert.match(adminAcceptance, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's'),
|
||||
`${module} must be registered in CONFIGURED_MODULES`)
|
||||
assert.ok(existsSync(resolve(scriptsDir, 'admin-test-env-modules', `${module}.mjs`)),
|
||||
`${module} module file is missing`)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user