From 51291b4c2775a03faaeaec2446b4a044901295d3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 4 Nov 2026 18:08:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firstleg/AGENTS.md | 2 +- lastleg-service/AGENTS.md | 13 ++- scripts/README.md | 4 +- scripts/acceptance-overview.mjs | 13 ++- scripts/acceptance-overview.test.mjs | 30 ++++- scripts/admin-test-env-acceptance.mjs | 82 ++++++++++--- scripts/admin-test-env-modules/README.md | 4 +- .../fixed-resource-readonly-contract.test.mjs | 63 ++++++++++ .../fixed-resource-readonly.mjs | 70 +++++++++++ ...nt-qualification-fixture-contract.test.mjs | 7 +- ...ixed-container-receiving-contract.test.mjs | 30 ++++- .../mixed-container-receiving.mjs | 23 +++- ...-test-env-report-summary-contract.test.mjs | 109 +++++++++++++++++- scripts/agents-index-contract.ps1 | 12 +- ...ient-test-env-acceptance-contract.test.mjs | 23 ++++ scripts/client-test-env-acceptance.mjs | 30 ++++- 16 files changed, 468 insertions(+), 47 deletions(-) create mode 100644 scripts/admin-test-env-modules/fixed-resource-readonly-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fixed-resource-readonly.mjs diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 07cc594ff..08a4de075 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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`;查“柜次费用明细与跨客户分摊组” | diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index a0c618ffc..0d32c399c 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 | diff --git a/scripts/README.md b/scripts/README.md index e47f45fbf..fc086f0d1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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`。 diff --git a/scripts/acceptance-overview.mjs b/scripts/acceptance-overview.mjs index 14e57e840..e5830e448 100644 --- a/scripts/acceptance-overview.mjs +++ b/scripts/acceptance-overview.mjs @@ -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))], diff --git a/scripts/acceptance-overview.test.mjs b/scripts/acceptance-overview.test.mjs index cd18b6b0d..11b0d2f56 100644 --- a/scripts/acceptance-overview.test.mjs +++ b/scripts/acceptance-overview.test.mjs @@ -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') +}) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index c1d5c79f9..62e2d7da7 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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}`) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 073e6300c..4e407b9e4 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。 diff --git a/scripts/admin-test-env-modules/fixed-resource-readonly-contract.test.mjs b/scripts/admin-test-env-modules/fixed-resource-readonly-contract.test.mjs new file mode 100644 index 000000000..1297a0238 --- /dev/null +++ b/scripts/admin-test-env-modules/fixed-resource-readonly-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/fixed-resource-readonly.mjs b/scripts/admin-test-env-modules/fixed-resource-readonly.mjs new file mode 100644 index 000000000..c139e46c3 --- /dev/null +++ b/scripts/admin-test-env-modules/fixed-resource-readonly.mjs @@ -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 + } +} diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index d3c24b346..03de953a8 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -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)) }) diff --git a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs index be17d56ef..597f48f30 100644 --- a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs +++ b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs @@ -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)), /正常完成后新键扫码不能重新收货/) +}) diff --git a/scripts/admin-test-env-modules/mixed-container-receiving.mjs b/scripts/admin-test-env-modules/mixed-container-receiving.mjs index 44a1e503e..ca22260e9 100644 --- a/scripts/admin-test-env-modules/mixed-container-receiving.mjs +++ b/scripts/admin-test-env-modules/mixed-container-receiving.mjs @@ -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) diff --git a/scripts/admin-test-env-report-summary-contract.test.mjs b/scripts/admin-test-env-report-summary-contract.test.mjs index e008bf66f..6869611ad 100644 --- a/scripts/admin-test-env-report-summary-contract.test.mjs +++ b/scripts/admin-test-env-report-summary-contract.test.mjs @@ -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') diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 1ac2ad0c9..f1ef86e56 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -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' '海外费用事件来源版本' diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 0aeb82b71..e89dfd85d 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -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'\]/) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index f1ef2f498..eefe83b0c 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -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,