This commit is contained in:
2026-11-04 18:08:19 +08:00
parent 0993dd446e
commit 51291b4c27
16 changed files with 468 additions and 47 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
| 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” |
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” |
| FBA 计价、私人地址人工报价、客户价格簿与柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“私人地址”“客户专属价格簿、柜级阶梯运费与调价边界” |
| 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” |
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
+7 -6
View File
@@ -44,7 +44,7 @@
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| 跨客户同仓预约、日历 | `InboundPlanController.java`;在途/外接货跟进与覆盖见`appointmentplanning/` |
| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 入仓、私人地址收货、实收与上架完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` |
@@ -56,7 +56,8 @@
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;发布字段仅随智能排车创建保存且缺省不发布,编辑命令不接收该字段;重发须按原门禁取消排车再重新创建,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
| 散板需求列表与货物登记 | `dispatch/service/DispatchCapacityDemandService.java`、`dispatch/controller/LooseboardCargoController.java`;列表与加货接口分开,发布规则查拼车主契约 |
| 排车散板提货/派送执行与取证 | `dispatch/controller/DispatchLooseboardExecutionController.java`、`dispatch/service/DispatchLooseboardExecutionService.java`;契约 `src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml` |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
@@ -65,7 +66,7 @@
| 货物废弃、待审锁箱、实际确认扣减 | `discard/controller/CargoDiscardController.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` |
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
| 异常回仓建单与 PDA 分批实收 | `exceptionorder/controller/ExceptionOrderController.java`、`PdaExceptionReturnController.java`;契约 `src/main/resources/openapi/exception-return-inbound-v1.yaml` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
| 海外业务费用来源、费用明细审核与重试 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`;目录入口 `financefee/controller/PlatformOverseasFeeCatalogController.java`,细则查详细索引同主题 |
@@ -74,10 +75,10 @@
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 |
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同 `fleet-vehicle-records-v1.yaml`。维修救援经 `financefee/fleet/VehicleCareFinanceAdapter` 入公司成本;Finance 为付款权威,未关联为 UNLINKED,不生成客户应收;权限、幂等与更正见详细索引同主题 |
| 车次计划、车/司机占用与排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;协作入口 `dispatch/fleet/DispatchFleetAdapter.java`、`FleetDispatchBindingGuard`、`VehicleAssignmentGuard`,不穿透车辆持久化;契约 `fleet-trips-v1.yaml` |
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java`;两级审核、依据失效与零扫码退回规则查当前排车主契约 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;来源、实收及资金边界查 `fleet-trips-v1.yaml` 与详细索引同主题 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` |
| 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
+3 -1
View File
@@ -51,7 +51,9 @@ node scripts/acceptance-overview.mjs --report reports/<明确的一次验收报
总览显式读取一份报告,不遍历历史目录挑选绿灯。`FAILED` 表示有失败、fatal 或阻断缺口;`INCOMPLETE` 表示有跳过、非阻断缺口、计划模块未出现或仅准备/局部模式;`RECORDED_CHECKS_PASSED` 只表示所记录检查通过,**不是当前版本全量通过**。旧报告没有执行计划时显示 `UNKNOWN_LEGACY_REPORT`,不反推范围。报告损坏或格式不支持时退出1,不自动重跑;正常分析退出0,即使报告失败也不作为 CI 放行命令。
新 Admin 报告的 `executionScope` 保存申请模块、依赖展开后的计划、当轮默认范围及模式限制;这里只记录计划,不以注册即视为已执行。总览将有请求失败与无请求失败分开统计,后者包含模块退出/前置不足,不能把失败条数直接当作独立 Bug 数。缺少模块断言也仅表示缺少证据,不猜测未执行原因。
新 Admin 报告的 `executionScope` 保存申请模块、依赖展开后的计划、当轮默认范围、未选择的专项及模式限制,并分别列出 `completedModules`、`skippedModules`、`failedModules`、`unexecutedModules` 和 `unverifiedModules`。模块实际成功且有非零断言才计为完成;`planCompleted` 不代表全部业务覆盖。`fullCoverage` 仅在默认计划全部实际完成、结果成功、没有覆盖缺口/跳过且不是准备或局部模式时为 true;`coverageStatus` 区分 `FAILED`、`PARTIAL`、`FULL`,清理结束后重新计算。总览将有请求失败与无请求失败分开统计,后者包含模块退出/前置不足,不能把失败条数直接当作独立 Bug 数。
Admin 固定资源模式对客户、系统、公共主档、通知、价格簿及海外主档执行 `READ_ONLY` 查询核验,写入生命周期仍保留 coverage gap;只有纯写入边界专项整体跳过。Client 补查固定客户企业资料、资质、地址及既有角色、成员和权限,原写入/隔离缺口仍明确跳过。两端均不因只读通过宣称完整生命周期已验收。默认 Admin 计划还包含 `mixed-container-receiving`,放在主业务与财务链之后,以独立上下文和资源区执行混柜、私人地址全收及短少确认,避免覆盖主链订单证据;灰度范围限制不变。
最新部署与已验/未验状态仍只维护在外层 `docs/测试环境单机部署与配置契约.md`;本索引不复制每日进度。总览不校验实际部署版本、不读取环境文件,执行前仍须经正式入口校验凭据和环境。直接离线测试为 `acceptance-overview.test.mjs`。
+9 -4
View File
@@ -8,10 +8,10 @@ import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
export function describeHomepageBusinessCoverage() {
const scope = describeAcceptanceScope()
return [
{ title: '头程下单', route: '/orders/first-leg/create', modules: ['orders', 'phase5-overseas-fulfillment'],
{ title: '头程下单', route: '/orders/first-leg/create', modules: ['orders', 'phase5-overseas-fulfillment', 'mixed-container-receiving'],
additionalModules: ['phase5-root-fulfillment', 'dropship-pallet-matrix'],
covered: '默认头程至海外履约主链;拆分根单、一件代发矩阵为可选专项',
remaining: '各订单类型、税制及根/余量分支未由默认链全部执行' },
covered: '默认头程至海外履约主链,以及混柜收货、私人地址全收与短少确认;拆分根单和其他一件代发矩阵为可选专项',
remaining: '全部税制、根/余量及一件代发出库矩阵未由默认链全部执行' },
{ title: '退货换标', route: '/overseas-warehouse/return-relabel/create', modules: ['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
covered: '退货预报、审核、签收、合箱、换标、客户确认及换标后出库',
remaining: '换标、客户确认及换标后出库已接入默认后续模块;固定客户跨客户边界仍缺资源,真实HTTP待验' },
@@ -53,16 +53,21 @@ export function summarizeReport(report) {
// 历史报告不包含计划信息,不能从断言数量或模块名字推定完整执行。
const scopeKnown = Array.isArray(scope?.plannedModules) && scope.plannedModules.length > 0
&& Array.isArray(scope.defaultModules) && scope.defaultModules.length > 0
const observedModules = new Set(steps.map((step) => step.module))
const observedModules = new Set(Array.isArray(report.modules)
? report.modules.filter(module => !module.skipped && module.success === true && Number(module.passed) > 0).map(module => module.name)
: steps.map((step) => step.module))
const missingModules = scopeKnown ? scope.plannedModules.filter((name) => !observedModules.has(name)) : []
const limited = Boolean(scope?.preparationOnly || scope?.returnMainChainOnly || report.fullAcceptance === false)
const failedResult = Boolean(report.fatal) || report.success === false || failed.length > 0 || blockers.length > 0
|| Boolean(scope?.stoppedByRequest) || (report.deploymentBlockers || []).length > 0
|| (report.modules || []).some(module => !module.skipped && (module.success === false || module.fatal))
return {
result: failedResult ? 'FAILED' : skipped.length || skippedModules || gaps.length || missingModules.length || limited ? 'INCOMPLETE' : 'RECORDED_CHECKS_PASSED',
passed: steps.filter((step) => step.passed && !step.skipped).length,
failed: failed.length,
skipped: skipped.length,
skippedModules,
readOnlyModules: (report.modules || []).filter(module => module.coverageMode === 'READ_ONLY').map(module => module.name),
blockingGaps: blockers.length,
nonBlockingGaps: gaps.length - blockers.length,
failedModules: [...new Set(failed.map((step) => step.module).filter(Boolean))],
+29 -1
View File
@@ -47,10 +47,17 @@ test('注册表返回独立副本,默认与可选不重复', () => {
assert.ok(scope.defaultModules.includes('appointment-work'))
assert.ok(scope.defaultModules.includes('phase4-overseas-return-followups'))
assert.ok(scope.optionalModules.every((name) => !scope.defaultModules.includes(name)))
assert.ok(scope.fixedResourceSkippedModules.includes('customers'))
assert.ok(scope.defaultModules.includes('mixed-container-receiving'))
assert.equal(scope.defaultModules.at(-1), 'mixed-container-receiving')
assert.equal(scope.defaultModules.filter(name => name === 'mixed-container-receiving').length, 1)
assert.ok(scope.fixedResourceReadOnlyModules.includes('customers'))
assert.ok(!scope.fixedResourceSkippedModules.includes('customers'))
assert.ok(scope.fixedResourceSkippedModules.includes('supplemental-boundaries'))
assert.ok(!scope.fixedResourceSkippedModules.includes('suppliers'))
scope.fixedResourceSkippedModules.length = 0
assert.ok(describeAcceptanceScope().fixedResourceSkippedModules.length > 0)
scope.fixedResourceReadOnlyModules.length = 0
assert.ok(describeAcceptanceScope().fixedResourceReadOnlyModules.length > 0)
scope.defaultModules.length = 0
assert.ok(describeAcceptanceScope().defaultModules.length > 0)
})
@@ -87,3 +94,24 @@ test('准备数据和局部模式不冒充完整验收,空报告拒绝', () =>
assert.equal(summarizeReport({ fullAcceptance: false, steps: [{ passed: true }] }).result, 'INCOMPLETE')
assert.throws(() => summarizeReport({ success: true, steps: [] }), /steps/)
})
test('混柜的内部步骤按实际模块完成记录归属,不因步骤标签不同而判漏跑', () => {
const report = { success: true, steps: [{ module: 'dropship-pallet-matrix', passed: true }],
modules: [{ name: 'mixed-container-receiving', success: true, passed: 1, failed: 0 }],
executionScope: { plannedModules: ['mixed-container-receiving'], defaultModules: ['mixed-container-receiving'] } }
assert.equal(summarizeReport(report).result, 'RECORDED_CHECKS_PASSED')
assert.deepEqual(summarizeReport(report).missingPlannedModules, [])
report.modules[0].passed = 0
assert.equal(summarizeReport(report).result, 'INCOMPLETE')
})
test('模块退出或清理阻断不能被成功字段掩盖,只读通过单独显示', () => {
const report = { success: true, steps: [{ module: 'system', passed: true }],
modules: [{ name: 'system', success: true, passed: 1, coverageMode: 'READ_ONLY' }] }
assert.deepEqual(summarizeReport(report).readOnlyModules, ['system'])
report.modules[0].success = false
assert.equal(summarizeReport(report).result, 'FAILED')
report.modules[0].success = true
report.deploymentBlockers = [{ module: 'cleanup' }]
assert.equal(summarizeReport(report).result, 'FAILED')
})
+65 -17
View File
@@ -5,17 +5,17 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import { normalizeFixedCustomerId, requireFixedSecondaryCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
import { FIXED_RESOURCE_READONLY_MODULES, runFixedResourceReadOnly } from './admin-test-env-modules/fixed-resource-readonly.mjs'
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-return-followups', '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 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-return-followups', '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', 'mixed-container-receiving']
const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const FIXED_RESOURCE_SKIPPED_MODULES = new Set(['customers', 'supplemental-boundaries', 'system', 'master-data',
'notifications', 'pricing', 'phase4-overseas-master-data'])
const FIXED_RESOURCE_SKIPPED_MODULES = new Set(['supplemental-boundaries'])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -295,6 +295,7 @@ export function describeAcceptanceScope() {
defaultModules: [...DEFAULT_MODULES],
optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)),
fixedResourceSkippedModules: [...FIXED_RESOURCE_SKIPPED_MODULES],
fixedResourceReadOnlyModules: [...FIXED_RESOURCE_READONLY_MODULES],
}
}
const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
@@ -675,6 +676,33 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps =
}
}
/** 同一规则用于正常结束及 finally 清理之后,覆盖率不能脱离实际执行结果。 */
export function finalizeAcceptanceReport(report) {
const steps = report.steps || [], modules = report.modules || [], gaps = report.coverageGaps || []
const scope = report.executionScope
const planned = [...new Set(scope.plannedModules)]
const rows = name => modules.filter(module => module.name === name)
scope.completedModules = planned.filter(name => rows(name).length > 0 && rows(name).every(module =>
!module.skipped && module.success === true && Number(module.passed) > 0 && Number(module.failed) === 0))
scope.skippedModules = planned.filter(name => rows(name).some(module => module.skipped))
scope.failedModules = planned.filter(name => rows(name).some(module => !module.skipped
&& (module.success === false || module.fatal || Number(module.failed) > 0)))
scope.unexecutedModules = planned.filter(name => rows(name).length === 0)
scope.unverifiedModules = planned.filter(name => !scope.completedModules.includes(name)
&& !scope.skippedModules.includes(name) && !scope.failedModules.includes(name))
scope.defaultPlanSelected = scope.defaultModules.length > 0 && scope.defaultModules.every(name => planned.includes(name))
scope.planCompleted = planned.length > 0 && scope.completedModules.length === planned.length
report.summary = buildAcceptanceSummary(steps, modules, gaps)
report.success = !report.fatal && !scope.stoppedByRequest && planned.length > 0
&& report.summary.failed === 0 && !gaps.some(gap => gap.blocking || gap.deploymentBlocker)
&& !(report.deploymentBlockers || []).length && !scope.failedModules.length && !scope.unverifiedModules.length
report.fullCoverage = report.success && scope.planCompleted && scope.defaultPlanSelected
&& !scope.preparationOnly && !scope.returnMainChainOnly && report.fullAcceptance !== false
&& gaps.length === 0 && !steps.some(step => step.skipped)
report.coverageStatus = !report.success ? 'FAILED' : report.fullCoverage ? 'FULL' : 'PARTIAL'
return report
}
export class AcceptanceRun {
constructor(options) {
assertGrayExecutionScope(options)
@@ -885,13 +913,35 @@ export class AcceptanceRun {
reason: '固定资源模式排除生命周期;不计为验收通过' })
return
}
const readOnly = Boolean(this.options.fixedCustomerId && FIXED_RESOURCE_READONLY_MODULES.includes(name))
const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null
try { await handler() } catch (error) {
try {
if (readOnly) await runFixedResourceReadOnly(this, name)
else await handler()
} catch (error) {
fatal = error instanceof Error ? error.message : String(error)
this.addStep(name, '模块执行', null, false, fatal)
}
const steps = this.steps.slice(start)
this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal })
this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal,
...(readOnly ? { coverageMode: 'READ_ONLY', lifecycleSkipped: true } : {}) })
}
async runIsolatedModule(name, handler) {
const child = Object.create(this)
child.options = { ...this.options }
child.context = { ...this.context }
child.createdResources = {}
child.phase4ProducerAttempts = new Map()
child.marker = `${this.marker}-MIXED`
try { await handler(child) }
finally {
for (const field of ['token', 'refreshToken', 'expiresAt']) {
if (Object.hasOwn(child, field)) this[field] = child[field]
}
this.createdResources.isolatedModules ??= {}
this.createdResources.isolatedModules[name] = child.createdResources
}
}
/** 在模块边界结束,随后仍由main的finally恢复故障注入配置并写出失败报告。 */
@@ -2040,7 +2090,9 @@ export class AcceptanceRun {
try {
const extension = await import(new URL(`./admin-test-env-modules/${module}.mjs`, import.meta.url))
if (typeof extension.run !== 'function') throw new Error('模块必须导出 async function run(context)')
handler = () => extension.run(this)
handler = module === 'mixed-container-receiving'
? () => this.runIsolatedModule(module, child => extension.run(child))
: () => extension.run(this)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.addStep(module, '模块注册检查', null, false, `无法加载模块: ${reason}`)
@@ -2067,7 +2119,7 @@ export class AcceptanceRun {
}
const laterOverseasModule = this.options.modules.slice(moduleIndex + 1)
.some((name) => name.startsWith('phase4-overseas-'))
if (module === 'system' && laterOverseasModule) {
if (module === 'system' && laterOverseasModule && !this.options.fixedCustomerId) {
const refreshed = await this.refreshSession('system模块权限与角色变更完成')
if (refreshed) {
const probe = await this.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
@@ -2105,15 +2157,14 @@ export class AcceptanceRun {
this.addStep('report-security', '报告敏感信息扫描', null, !sensitive,
sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段')
const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps)
return {
return finalizeAcceptanceReport({
reportName: this.options.prepareDomesticPdaH5 ? '国内 PDA H5 待拣任务准备(非全链路验收)' : this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker,
environment: this.options.environment || 'test', gateway: this.options.gateway,
executionScope: {
requestedModules: [...(this.options.requestedModules || [])],
plannedModules: [...this.options.modules],
defaultModules: [...DEFAULT_MODULES],
defaultPlanSelected: DEFAULT_MODULES.every((name) => this.options.modules.includes(name)),
notSelectedModules: [...AVAILABLE_MODULES].filter(name => !this.options.modules.includes(name)),
stoppedByRequest: this.stopRequested === true,
preparationOnly: this.options.prepareDomesticPdaH5 === true || this.options.prepareDropshipManualCount === true || this.options.prepareMeasurementReview === true,
returnMainChainOnly: this.options.returnMainChainOnly === true,
@@ -2121,11 +2172,9 @@ export class AcceptanceRun {
pageAcceptance: 'NOT_VERIFIED',
},
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
fullCoverage: this.coverageGaps.length === 0 && !this.modules.some(module => module.skipped),
fatal: this.fatal,
...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}),
...(this.options.prepareMeasurementReview ? { fullAcceptance: false, purpose: 'MEASUREMENT_REVIEW_UI_FIXTURE' } : {}),
summary,
modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers,
coverageGaps: this.coverageGaps,
createdResources: this.createdResources,
@@ -2136,7 +2185,7 @@ export class AcceptanceRun {
],
securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。',
steps: this.steps,
}
})
}
}
@@ -2208,8 +2257,7 @@ async function main() {
catch (error) { run.addStep('phase4-cross-region-events', '故障验收后必须恢复原服务配置与健康状态', null,
false, String(error.message), { scenario: 'direct-delivery-replay', deploymentBlocker: true }) }
}
report.summary = buildAcceptanceSummary(run.steps, run.modules, run.coverageGaps)
report.success = report.fatal === null && report.summary.failed === 0
finalizeAcceptanceReport(report)
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(`REPORT=${outputPath}`)
+2 -2
View File
@@ -72,7 +72,7 @@
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。业务订单与实物履约证据仍会新增。
固定客户模式下,`fixed-resource-readonly.mjs` 为 `customers`、`system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 执行既有客户、初始化状态、权限/角色/账号、仓库、通知模板/任务、价格簿与海外主档的只读核验;记录 `coverageMode=READ_ONLY` 和 `lifecycleSkipped=true`,写入生命周期继续列为 coverage gap,不以查询通过代替写入验收。离线边界由 `fixed-resource-readonly-contract.test.mjs` 验证。纯写入边界 `supplemental-boundaries` 仍整模块 skipped。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
@@ -264,7 +264,7 @@ export async function run(context) {
混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
`mixed-container-receiving.mjs` 已接入默认全量,也可通过总入口 `--modules mixed-container-receiving` 定向执行,依赖客户资质准备。总入口使用独立 marker、上下文和资源区,报告保存在 `createdResources.isolatedModules["mixed-container-receiving"].dropshipPalletMatrix`,不覆盖主链订单/财务证据;内部直接调用的资源结构不变。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。内部助手使用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
@@ -0,0 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
import { FIXED_RESOURCE_READONLY_MODULES } from './fixed-resource-readonly.mjs'
function runner(mutate = value => value) {
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
const calls = []
context.request = async (method, path) => {
calls.push({ method, path })
assert.equal(method, 'GET', '固定资源只读检查不得写入或创建资源')
let value = path.includes('?page=') ? { page: 1, size: 20, total: 0, records: [] } : []
if (path.endsWith('/tenants/123')) value = { id: '123', status: 'ACTIVE' }
if (path.endsWith('/dimension-initialization')) value = { status: 'READY' }
if (path.endsWith('/permissions')) value = [{ id: '1', permissionCode: 'platform:customer:list' }]
if (path.endsWith('/roles')) value = [{ id: '2', roleCode: 'EXISTING_ROLE' }]
if (path.includes('/iam/users?')) value.records = [{ membershipId: '3', tenantId: '0' }], value.total = 1
if (path.includes('/warehouse-admin?')) value.records = [{ id: '4', tenantId: '0' }], value.total = 1
return { method, path, http: 200, body: { code: '200', data: mutate(value, path) } }
}
return { context, calls }
}
for (const module of FIXED_RESOURCE_READONLY_MODULES) test(`${module}复用现有数据,只读通过且保留生命周期缺口`, async () => {
const { context, calls } = runner()
await context.executeModule(module, () => assert.fail('不得调用原生命周期处理器'))
const result = context.modules[0]
assert.equal(result.success, true)
assert.ok(result.passed > 0)
assert.equal(result.skipped, undefined)
assert.equal(result.coverageMode, 'READ_ONLY')
assert.equal(result.lifecycleSkipped, true)
assert.ok(calls.length > 0)
assert.ok(calls.every(row => row.method === 'GET'))
assert.equal(context.coverageGaps.length, 1)
assert.equal(context.coverageGaps[0].blocking, false)
assert.equal(context.createdResources.fixedResourceReadOnly[module].lifecycle, 'NOT_TESTED')
})
for (const [label, module, mutate] of [
['客户不匹配', 'customers', (value, path) => path.endsWith('/123') ? { ...value, id: '999' } : value],
['客户被停用', 'customers', (value, path) => path.endsWith('/123') ? { ...value, status: 'DISABLED' } : value],
['初始化未就绪', 'customers', (value, path) => path.endsWith('/dimension-initialization') ? { status: 'PENDING' } : value],
['仓库非公共归属', 'master-data', value => ({ ...value, records: [{ id: '4', tenantId: '999' }], total: 1 })],
['平台账号非平台归属', 'system', (value, path) => path.includes('/users?')
? { ...value, records: [{ membershipId: '3', tenantId: '123' }] } : value],
['分页结构缺失', 'notifications', () => []],
['分页范围错误', 'notifications', value => ({ ...value, page: 2 })],
['价格目录结构错误', 'pricing', () => ({ records: [] })],
]) test(`${label}必须失败,禁止补建或执行原写入处理器`, async () => {
const { context, calls } = runner(mutate)
await context.executeModule(module, () => assert.fail('不能回退创建数据'))
assert.equal(context.modules[0].success, false)
assert.ok(context.modules[0].failed > 0)
assert.ok(calls.every(row => row.method === 'GET'))
})
test('权限错误保留为真实失败,不以空目录通过', async () => {
const { context } = runner()
context.request = async () => ({ http: 403, body: { code: 'A0403' } })
await context.executeModule('system', () => assert.fail('不能修改授权'))
assert.equal(context.modules[0].success, false)
})
@@ -0,0 +1,70 @@
import { normalizeFixedCustomerId } from './merchant-qualification-fixture.mjs'
export const FIXED_RESOURCE_READONLY_MODULES = Object.freeze([
'customers', 'system', 'master-data', 'notifications', 'pricing', 'phase4-overseas-master-data',
])
const IAM = '/api/platform/v1/iam'
const MASTER = '/api/platform/v1/last-leg/master-data'
const decimalId = value => /^[1-9]\d*$/.test(String(value ?? ''))
const pageShape = value => Array.isArray(value?.records)
&& Number(value.page) === 1 && Number(value.size) === 20
&& ['string', 'number'].includes(typeof value.total) && /^(0|[1-9]\d*)$/.test(String(value.total))
&& Number.isSafeInteger(Number(value.total))
&& Number(value.total) >= value.records.length && value.records.length <= 20
/** 固定资源只检查现有数据;生命周期缺口保留,不能以查询通过代替写入验收。 */
export async function runFixedResourceReadOnly(context, module) {
if (!context.options.fixedCustomerId || !FIXED_RESOURCE_READONLY_MODULES.includes(module))
throw Error('固定资源只读检查必须指定已有客户及受支持模块')
context.addCoverageGap(module, 'fixed-resource-lifecycle', '现有资源只读检查不覆盖创建、修改、发布及停用生命周期',
'复用已有客户、账号、角色和主档;生命周期仍为未验收', 'not-implemented', { blocking: false })
const proof = { mode: 'READ_ONLY', lifecycle: 'NOT_TESTED', checks: [] }
context.createdResources.fixedResourceReadOnly ??= {}
context.createdResources.fixedResourceReadOnly[module] = proof
async function get(label, path, validate) {
const response = await context.request('GET', path)
const value = context.apiSuccess(module, label, response)
const passed = response?.http === 200 && String(response.body?.code) === '200' && validate(value)
context.assert(module, `${label}结构及归属有效`, response, Boolean(passed), '', { deploymentBlocker: true })
proof.checks.push({ path, passed: Boolean(passed),
...(Array.isArray(value) ? { count: value.length }
: Array.isArray(value?.records) ? { count: value.records.length, total: Number(value.total) } : {}) })
if (!passed) throw Error(`${module}现有资源只读核验失败,禁止补建或修改`)
}
const page = (label, path, validate = () => true) => get(label, `${path}?page=1&size=20`,
value => pageShape(value) && value.records.every(validate))
switch (module) {
case 'customers': {
const customerId = normalizeFixedCustomerId(context.options.fixedCustomerId)
await get('读取指定固定客户档案', `${IAM}/tenants/${customerId}`,
value => String(value?.id) === customerId && value.status === 'ACTIVE')
await get('读取指定固定客户尺寸初始化状态', `${IAM}/tenants/${customerId}/dimension-initialization`,
value => value?.status === 'READY')
break
}
case 'system':
await get('读取既有权限目录', `${IAM}/permissions`, value => Array.isArray(value)
&& value.every(row => decimalId(row.id) && typeof row.permissionCode === 'string'))
await get('读取既有角色目录', `${IAM}/roles`, value => Array.isArray(value)
&& value.every(row => decimalId(row.id) && typeof row.roleCode === 'string'))
await page('读取既有平台账号分页', `${IAM}/users`, row => decimalId(row.membershipId) && String(row.tenantId) === '0')
break
case 'master-data':
await page('读取既有公共仓库分页', '/api/platform/v1/warehouse-admin',
row => decimalId(row.id) && String(row.tenantId) === '0')
break
case 'notifications':
await page('读取既有通知模板分页', '/api/platform/v1/notification-templates')
await page('读取既有通知任务分页', '/api/platform/v1/notification-tasks')
break
case 'pricing':
await get('读取既有价格簿目录', '/api/platform/v1/fba-pricing/carrier-rates/price-books', Array.isArray)
break
case 'phase4-overseas-master-data':
await page('读取既有海外报价分页', `${MASTER}/overseas-quotes`)
await page('读取既有汇率分页', `${MASTER}/exchange-rates`)
await page('读取既有海外车辆分页', `${MASTER}/vehicles`)
break
}
}
@@ -109,15 +109,14 @@ test('cross-customer financial helpers stop before provisioning or writing', asy
}
})
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
test('fixed resource runner excludes mutation-only boundary handlers without marking them passed', async () => {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
let calls = 0
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'notifications', 'pricing',
'phase4-overseas-master-data']) {
for (const name of ['supplemental-boundaries']) {
await runner.executeModule(name, async () => { calls++ })
}
assert.equal(calls, 0)
assert.equal(runner.modules.length, 7)
assert.equal(runner.modules.length, 1)
assert.ok(runner.modules.every(module => module.skipped && !module.success))
})
@@ -46,7 +46,7 @@ test('schedule interleaves three modes and uses FBA cumulative count rather than
})
function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false, repeatIncrement = false,
changedPayloadAccepted = false } = {}) {
changedPayloadAccepted = false, foreignPalletAccepted = false } = {}) {
const group = fixtures()
const expected = new Map(group.map(r => [r.source.inboundCode, 0]))
const schedule = mixedReceivingSchedule(group)
@@ -60,6 +60,9 @@ function driver({ earlyComplete = false, wrongModeAccepted = false, drift = fals
receivedPackages, pendingPackages: 4 - receivedPackages })) })
return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path, body) => {
if (path.endsWith('/pallets/resolve')) return { body: {
code: foreignPalletAccepted ? '200' : body.inboundCode === 'FBA' ? 'A0404' : 'A0409',
} }
if (body.idempotencyKey.endsWith('-duplicate')) {
return { body: path.endsWith('/carton') ? { code: 'A0409' }
: { code: '200', data: { result: 'ALREADY_RECEIVED', acceptedPackages: repeatIncrement ? 1 : 0 } } }
@@ -92,6 +95,10 @@ test('mixed receiving executes the whole schedule and completes only after all t
test('mixed receiving fails on early-completion acceptance', async () => {
await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true })))
})
test('mixed receiving fails if another inbound real reserved pallet is accepted', async () => {
await assert.rejects(receiveMixedContainer(driver({ foreignPalletAccepted: true })), /同柜另一票真实预留托盘必须拒绝/)
})
test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => {
await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true })))
})
@@ -105,7 +112,7 @@ test('mixed receiving rejects same-key different-payload success', async () => {
await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true })))
})
function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {}) {
function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false, postCompleteReceiptAccepted = false } = {}) {
const record = { order: { tenantId: 'CUSTOMER' }, source: { inboundCode: 'IN-OVERSEAS' },
expectedCartonMarks: [1, 2, 3, 4].map(index => `IN-OVERSEAS#4-${index}`),
overseas: { plan: { planNo: 'IP-SHORTAGE', pallets: [{ palletNo: 'PAL-1', warehousePalletId: '900' }] } } }
@@ -114,7 +121,12 @@ function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {})
const state = { received: 0, status: 'SIGNED', planStatus: 'PENDING_RECONCILIATION', confirmations: 0 }
const snapshot = () => ({ status: state.status, receivedPackages: state.received, pendingPackages: 4 - state.received })
return { record, marker: 'SHORTAGE-TEST', state, check: (name, _response, passed) => assert.ok(passed, name),
request: async (_method, path) => ({ body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }),
request: async (_method, path, body) => {
if (/receive-after-(complete|shortage)$/.test(body.idempotencyKey || '')) {
return { body: { code: postCompleteReceiptAccepted ? '200' : 'A0404' } }
}
return { body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }
},
call: async (_name, method, path, body) => {
if (method === 'GET') return snapshot()
if (path.endsWith('/cartons/resolve')) return { inboundCode: record.source.inboundCode, destinationType: 'OVERSEAS' }
@@ -157,8 +169,12 @@ test('ordinary overseas shortage fails if stock includes an unreceived carton',
await assert.rejects(receiveOverseasShortage(shortageDriver({ stockPackages: 4 })))
})
function completeDriver(stockPackages = 4) {
const input = shortageDriver({ stockPackages })
test('ordinary overseas shortage fails if a missing carton can be received after confirmation', async () => {
await assert.rejects(receiveOverseasShortage(shortageDriver({ postCompleteReceiptAccepted: true })), /短少确认后不能继续收取未入库箱/)
})
function completeDriver(stockPackages = 4, postCompleteReceiptAccepted = false) {
const input = shortageDriver({ stockPackages, postCompleteReceiptAccepted })
const call = input.call
input.call = async (name, method, path, body) => {
if (path.endsWith('/complete')) {
@@ -184,3 +200,7 @@ test('ordinary overseas full receipt uses normal completion and stocks all four
test('ordinary overseas full receipt rejects stock inconsistent with actual received cartons', async () => {
await assert.rejects(receiveOverseasComplete(completeDriver(3)))
})
test('ordinary overseas full receipt fails if another receipt is accepted after completion', async () => {
await assert.rejects(receiveOverseasComplete(completeDriver(4, true)), /正常完成后新键扫码不能重新收货/)
})
@@ -33,6 +33,17 @@ export async function receiveMixedContainer({ group, call, check, request, marke
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const steps = mixedReceivingSchedule(group)
const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 }))
for (const record of group.filter(r => r.order.fulfillmentType === 'STANDARD')) {
const other = group.find(item => item !== record && item.order.fulfillmentType === 'STANDARD')
const before = await call('混柜串票托盘校验前回查', 'GET', base)
const invalid = await request('POST', `${base}/pallets/resolve`, {
inboundCode: record.source.inboundCode, palletCode: other.overseas.plan.pallets[0].palletNo,
})
check('同柜另一票真实预留托盘必须拒绝', invalid,
invalid.body?.code === (record.order.destinationType === 'FBA' ? 'A0404' : 'A0409'))
const after = await call('混柜串票托盘拒绝后回查', 'GET', base)
check('同柜串票托盘拒绝后全柜事实不变', null, JSON.stringify(before) === JSON.stringify(after))
}
for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) {
const before = await call('混柜错误收货模式前回查', 'GET', base)
const invalid = await request('POST', `${base}/receipts/bulk`, {
@@ -82,7 +93,7 @@ export async function receiveMixedContainer({ group, call, check, request, marke
return completed
}
export async function receiveOverseasComplete({ record, call, adminCall, check, marker }) {
export async function receiveOverseasComplete({ record, call, adminCall, check, request, marker }) {
const base = '/api/pda/v1/last-leg/receiving-containers/current'
const inboundCode = record.source.inboundCode
const plan = record.overseas.plan
@@ -104,6 +115,11 @@ export async function receiveOverseasComplete({ record, call, adminCall, check,
&& Number(completed.receivedPackages) === expected && Number(completed.pendingPackages) === 0)
const replay = await call('正常完成原键重放', 'POST', `${base}/complete`, body)
check('正常完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
const afterComplete = await request('POST', `${base}/receipts/carton`, {
cartonMark: record.expectedCartonMarks[0], palletCode: pallet.palletNo,
idempotencyKey: `${marker}-receive-after-complete`,
})
check('正常完成后新键扫码不能重新收货', afterComplete, afterComplete.body?.code === 'A0404')
const finalPlan = await adminCall('回查全收最终计划', 'GET',
`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}?customerId=${encodeURIComponent(record.order.tenantId)}`)
check('全收计划完成且应收实收均为四箱', null, finalPlan.status === 'COMPLETED'
@@ -164,6 +180,11 @@ export async function receiveOverseasShortage({ record, call, adminCall, check,
check('普通海外仓短少确认成功', null, confirmed.status === 'PARTIAL_INBOUND')
const confirmReplay = await adminCall('后台短少确认同键重放', 'POST', `${planPath}/confirm-shortage`, confirmBody, headers)
check('后台短少确认重放不重复入库', null, JSON.stringify(confirmReplay) === JSON.stringify(confirmed))
const afterComplete = await request('POST', `${base}/receipts/carton`, {
cartonMark: record.expectedCartonMarks[actual], palletCode: pallet.palletNo,
idempotencyKey: `${marker}-receive-after-shortage`,
})
check('短少确认后不能继续收取未入库箱', afterComplete, afterComplete.body?.code === 'A0404')
const finalPlan = await adminCall('回查短少入库最终计划', 'GET', `${planPath}?customerId=${encodeURIComponent(customerId)}`)
check('短少入库保留原应收及实际箱数', null, finalPlan.status === 'PARTIAL_INBOUND'
&& Number(finalPlan.expectedPackageCount) === expected && Number(finalPlan.pickedPackageCount) === actual)
@@ -1,10 +1,117 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
import { AcceptanceRun, parseArguments, buildAcceptanceSummary, finalizeAcceptanceReport } from './admin-test-env-acceptance.mjs'
import { mkdtemp, writeFile, unlink, rmdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
test('登录失败且未执行业务模块不能报告全量覆盖', async () => {
const run = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true,
modules: ['dashboard'], requestedModules: ['dashboard'] })
run.request = async () => ({ http: 200, body: { code: 'A0401' } })
const report = await run.run()
assert.equal(report.success, false)
assert.equal(report.fullCoverage, false)
assert.equal(report.coverageStatus, 'FAILED')
assert.deepEqual(report.executionScope.unexecutedModules, ['dashboard'])
})
function completeReport() {
return { fatal: null, executionScope: { plannedModules: ['orders', 'finance'], defaultModules: ['orders', 'finance'] },
steps: [{ module: 'orders', passed: true }, { module: 'finance', passed: true }],
modules: ['orders', 'finance'].map(name => ({ name, success: true, passed: 1, failed: 0 })),
coverageGaps: [], deploymentBlockers: [] }
}
test('全量覆盖要求计划全部实际完成;定向成功单独保留通过结果', () => {
const full = finalizeAcceptanceReport(completeReport())
assert.equal(full.success, true)
assert.equal(full.fullCoverage, true)
assert.equal(full.coverageStatus, 'FULL')
const focused = completeReport()
focused.executionScope.plannedModules = ['orders']
focused.modules.pop(); focused.steps.pop()
finalizeAcceptanceReport(focused)
assert.equal(focused.success, true)
assert.equal(focused.fullCoverage, false)
assert.equal(focused.executionScope.planCompleted, true)
assert.equal(focused.coverageStatus, 'PARTIAL')
})
for (const [label, mutate, failed] of [
['模块未执行', report => { report.modules.pop() }, true],
['模块没有断言', report => { report.modules[1].passed = 0 }, true],
['模块失败但没有失败步骤', report => { report.modules[1].success = false }, true],
['中止', report => { report.executionScope.stoppedByRequest = true }, true],
['阻断覆盖缺口', report => { report.coverageGaps.push({ blocking: true }) }, true],
['外部部署阻断', report => { report.deploymentBlockers.push({ module: 'orders' }) }, true],
['跳过模块', report => { Object.assign(report.modules[1], { skipped: true, success: false, passed: 0 }) }, false],
['只读生命周期缺口', report => { report.coverageGaps.push({ blocking: false }) }, false],
['仅准备数据', report => { report.executionScope.preparationOnly = true }, false],
['退货仅主链', report => { report.executionScope.returnMainChainOnly = true }, false],
['显式局部验收', report => { report.fullAcceptance = false }, false],
]) test(`${label}不允许标记全量覆盖`, () => {
const report = completeReport(); mutate(report); finalizeAcceptanceReport(report)
assert.equal(report.success, !failed)
assert.equal(report.fullCoverage, false)
})
test('清理失败后重新计算报告,不能保留先前的成功与覆盖率', () => {
const report = finalizeAcceptanceReport(completeReport())
report.steps.push({ module: 'cleanup', passed: false })
finalizeAcceptanceReport(report)
assert.equal(report.success, false)
assert.equal(report.fullCoverage, false)
assert.equal(report.summary.failed, 1)
})
test('混柜专项隔离上下文与报告资源,失败仍保留本轮独立证据', async () => {
const run = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
run.context.orderDestinationType = 'FBA'
run.createdResources.orders = { orderNo: 'MAIN-ORDER' }
const marker = run.marker
await assert.rejects(run.runIsolatedModule('mixed-container-receiving', async child => {
assert.equal(child.fixedPdaLeases, run.fixedPdaLeases)
child.context.orderDestinationType = 'PRIVATE'
child.options.prepareDropshipManualCount = true
child.createdResources.orders = { orderNo: 'ISOLATED-ORDER' }
child.token = 'rotated-session'
child.refreshToken = 'rotated-refresh'
child.expiresAt = 123
child.marker = 'CHANGED'
throw Error('fixture failure')
}), /fixture failure/)
assert.equal(run.marker, marker)
assert.equal(run.context.orderDestinationType, 'FBA')
assert.equal(run.options.prepareDropshipManualCount, undefined)
assert.equal(run.token, 'rotated-session')
assert.equal(run.refreshToken, 'rotated-refresh')
assert.equal(run.expiresAt, 123)
assert.equal(run.createdResources.orders.orderNo, 'MAIN-ORDER')
assert.equal(run.createdResources.isolatedModules['mixed-container-receiving'].orders.orderNo, 'ISOLATED-ORDER')
})
test('真实总入口调度混柜模块必须经过隔离处理器', async () => {
const run = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true,
modules: ['mixed-container-receiving'], requestedModules: ['mixed-container-receiving'] })
run.request = async (method, path) => {
assert.equal(method, 'POST'); assert.equal(path, '/api/auth/login')
return { http: 200, body: { code: '200', data: { accessToken: 'mock-session', refreshToken: 'mock-refresh' } } }
}
let isolated = false
run.runIsolatedModule = async (name, handler) => {
assert.equal(name, 'mixed-container-receiving')
assert.equal(typeof handler, 'function')
isolated = true
run.addStep(name, 'offline isolated workflow', null, true)
}
const report = await run.run()
assert.equal(isolated, true)
assert.equal(report.success, true)
assert.equal(report.fullCoverage, false)
assert.deepEqual(report.executionScope.completedModules, ['mixed-container-receiving'])
})
test('explicit stop file waits for a module boundary and records an interrupted failure once', async () => {
const directory = await mkdtemp(join(tmpdir(), 'acceptance-stop-'))
const stopFile = join(directory, 'stop')
+10 -2
View File
@@ -616,7 +616,10 @@ $inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-se
Assert-Contains $businessFacts '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由'
Assert-Contains $businessFacts 'exceptionorder/' '根海外异常服务入口'
Assert-Contains $lastlegIndex '退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位' 'Lastleg 海外异常库存恢复规则'
Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
$exceptionReturnInboundContract = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml')
Assert-Contains $adminOverseasAgents '建单不恢复库存' 'Admin 异常回仓建单与实收分开'
Assert-Contains $exceptionReturnInboundContract '/api/pda/v1/last-leg/return-receipts/exceptions/{number}/receive:' '异常回仓专用 PDA 实收入口'
Assert-Contains $exceptionReturnInboundContract '收齐后异常COMPLETED,退回尾程INBOUND_COMPLETED' '异常与退回尾程按实收完成'
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
Assert-Contains $lastlegAgents '`RETURN_STOCK_IN` 流水' 'Lastleg 退回库存流水边界'
@@ -1194,7 +1197,12 @@ foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'PROCURE
Assert-Matches $overseasFinanceOpenApi 'collectionMode:\s*\{\s*type:\s*string,\s*enum:\s*\[BILL,\s*WALLET\]' '海外费用区分账单与余额收款'
Assert-Matches $overseasManualFeeService 'if\s*\(approved\s*&&\s*walletCollection\)\s*send\(fee,\s*state,\s*"SETTLE"\)' '余额费用审核发送实扣指令'
Assert-Matches $overseasManualFeeService 'else\s+if\s*\(approved\)\s*outbox\.publish\(' '账单费用审核保留可靠投递'
Assert-Contains $overseasFinanceOpenApi '已发布BILL作废等待Finance持久化确认才VOIDED' '账单作废等待财务确认'
# 人工费用审核前作废释放冻结;已有账单来源撤销仍在可靠确认后落账。
Assert-Contains $overseasFinanceOpenApi '已审核通过、已扣款或已发布均不允许这两个动作' '人工费用审核后不允许调价或作废'
Assert-Contains $overseasFinanceOpenApi '审核前作废直接关闭并释放冻结,不向Finance发送账单作废事件' '人工费用作废释放冻结且不撤销账单'
$feeCancellationAcknowledgement = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/financefee/state/OverseasFeeCancellation.java')
Assert-Contains $feeCancellationAcknowledgement 'public void acknowledge(' '已有费用来源撤销由投递确认推进'
Assert-Matches $feeCancellationAcknowledgement '@Transactional\(propagation\s*=\s*Propagation\.MANDATORY\)' '已有费用撤销确认与投递日志共用事务'
Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型'
Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本'
@@ -29,6 +29,29 @@ test('fixed Client authenticates and verifies ownership without registration', a
await assert.rejects(scope.reuseFixedClient(), /fixed customer identity/)
})
test('固定客户补回企业资料、资质及角色成员查询,全程只读且校验归属', async () => {
const calls = []
const scope = vm.createContext({ fixedCustomerId: '123', merchantToken: 'existing-session', report: {},
assertEqual: assert.equal, assertTrue: assert.ok,
requireSuccess: json => assert.equal(json.code, 200),
async apiStep(_module, _name, request, validate) {
calls.push(request)
assert.equal(request.method, 'GET')
assert.equal(request.token, 'existing-session')
const json = { code: 200, data: request.path.endsWith('/tenant/profile')
? { id: '123', status: 'ACTIVE' } : [] }
validate({ json }); return { json }
},
})
const helper = source.slice(source.indexOf('async function fixedClientReadOnlyModule()'), source.indexOf('async function registrationModule()'))
vm.runInContext(helper, scope)
await scope.fixedClientReadOnlyModule()
assert.equal(calls.length, 6)
assert.deepEqual([...scope.report.readOnlyCoverage], ['qualification-review', 'profile-management', 'rbac-members'])
scope.fixedCustomerId = '999'
await assert.rejects(scope.fixedClientReadOnlyModule(), /fixed customer read-only identity/)
})
test('client registration distinguishes incomplete-registration replay from credential conflicts', () => {
assert.match(source, /same credentials replay the incomplete registration/)
assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/)
+28 -2
View File
@@ -99,11 +99,12 @@ let temporaryOwnerPassword
try {
await reuseFixedClient()
await platformReviewerLogin()
await fixedClientReadOnlyModule()
for (const module of ['registration', 'qualification-review', 'profile-management', 'login-identifiers',
'rbac-members', 'isolation', 'order-submission-balance', 'order-mutation-evidence',
'order-submission-channel', 'notification-inbox', 'account-security']) {
recordSkipped(module, '固定客户模式排除生命周期或共享配置写入',
'不注册客户、不修改固定账号资料或余额、不发布通知;跨客户场景需第二个固定客户')
recordSkipped(module, '固定客户模式排除写入生命周期或尚未实现的边界',
'企业资料、资质和角色成员查询单独只读核验;注册、配置修改、账号安全及隔离边界仍未验收')
}
await orderLifecycleModule()
await orderExcelImportModule()
@@ -126,6 +127,8 @@ try {
if (!logout) report.success = false
}
report.completedAt = new Date().toISOString()
report.fullCoverage = report.success && !report.steps.some(step => step.skipped)
report.coverageStatus = !report.success ? 'FAILED' : report.fullCoverage ? 'FULL' : 'PARTIAL'
await mkdir(reportDir, { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
printSummary()
@@ -151,6 +154,29 @@ async function reuseFixedClient() {
report.testAccount = { username, reused: true }
}
async function fixedClientReadOnlyModule() {
await apiStep('profile-management', '固定客户企业资料只读核验', {
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.id), fixedCustomerId, 'fixed customer read-only identity')
assertEqual(json.data?.status, 'ACTIVE', 'fixed customer read-only status')
})
for (const [module, name, path] of [
['qualification-review', '既有企业资质', '/api/merchant/v1/iam/tenant/qualifications'],
['profile-management', '既有企业地址', '/api/merchant/v1/iam/tenant/addresses'],
['rbac-members', '既有客户角色', '/api/merchant/v1/iam/roles'],
['rbac-members', '既有客户成员', '/api/merchant/v1/iam/members'],
['rbac-members', '既有有效权限', '/api/merchant/v1/iam/authorization/permissions'],
]) {
await apiStep(module, `${name}只读核验`, { method: 'GET', path, token: merchantToken }, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data), `${name} must be an array`)
}, ({ json }) => ({ count: json.data.length, coverageMode: 'READ_ONLY' }))
}
report.readOnlyCoverage = ['qualification-review', 'profile-management', 'rbac-members']
}
async function registrationModule() {
const registrationBody = {
username,