优化
This commit is contained in:
@@ -72,7 +72,7 @@
|
||||
|
||||
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
|
||||
|
||||
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
|
||||
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。业务订单与实物履约证据仍会新增。
|
||||
|
||||
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
|
||||
|
||||
@@ -122,13 +122,13 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
|
||||
|
||||
`fleet-trip-cost-acceptance.mjs` 是同轮实际发车后的车次直接成本内部验收助手,由车辆专项入口显式调用 `verifyFleetTripCosts(context, module, tripProof, inputs)`,不独立造车次、不修改IAM。调用方须提供真实DEPART、独立确认的自有/外接逐票托盘及托位预期、仅列表权限和无车次权限两个独立会话,并负责退出;缺前置在成本写入前停止。写入本车次两条成本并更正,最终CNY100.03、USD13.07分别按整数分和真实托位核验分摊,检查重放、历史、权限拒绝后事实不变;公司维修成本不并入。请求意图和响应通过调用方checkpoint脱敏落盘,失败保留原样本/键,不自动重跑、删除或清理。`fleet-trip-cost-acceptance-contract.test.mjs` 为离线合同,不证明真实HTTP已通过。
|
||||
|
||||
`fleet-company-cost-acceptance.mjs` 是公司车辆成本内部验收模块,由 `../fleet-company-cost-test-env.mjs` 使用独立 marker 和 TEST 会话调用,不依赖或借用 `financeFullFixtures`。自行创建专用供应商、车辆及维修/救援记录,覆盖 CNY/USD 来源匹配、未付款作废重建、凭证字节、部分/全额付款重放、超付与已付改写拒绝;客户隔离只读检索本 marker 费用未进入客户费用池,不借用其他客户查询明细或账户,避免将其他并行任务的正常变化误判为污染。要求当月已有启用 USD 汇率,缺失则停止,不修改共享汇率。报告和清理边界见脚本根索引;`fleet-company-cost-acceptance-contract.test.mjs` 为离线替身测试,不调用 TEST。
|
||||
`fleet-company-cost-acceptance.mjs` 是公司车辆成本内部验收模块,由 `../fleet-company-cost-test-env.mjs` 使用独立 marker 和 TEST 会话调用,不依赖或借用 `financeFullFixtures`。从 `context.options.fixedCompanyCostResources` 读取既有 `supplierId`、`vehicleId`,只读核验供应商平台归属、启用状态及海外拖车类别,车辆须为海外自有在役;缺配置直接阻断,不创建、编辑或停用主档。维修/救援记录仍按本轮创建,覆盖 CNY/USD 来源匹配、未付款作废重建、凭证字节、部分/全额付款重放、超付与已付改写拒绝;客户隔离只读检索本 marker 费用未进入客户费用池,不借用其他客户查询明细或账户,避免将其他并行任务的正常变化误判为污染。要求当月已有启用 USD 汇率,缺失则停止,不修改共享汇率。报告和清理边界见脚本根索引;`fleet-company-cost-acceptance-contract.test.mjs` 为离线替身测试,不调用 TEST。
|
||||
|
||||
`fleet-driver-qualification.mjs` 是司机证照专项可选模块,从 `admin-test-env-acceptance.mjs --modules fleet-driver-qualification --output <报告路径>` 执行,凭据由调用者显式 `node --env-file=<授权TEST环境文件>` 提供。经正式API创建两名本轮受控司机、上传非真实证照PNG,验证缺件/审核/打回/重提、30天内提醒、任务结束跨到期日禁止、非必需资质不阻断、关键资料及附件变化重审、旧版本拒绝、附件跨司机归属和历史下载。仅操作本轮司机及文件,不改排车/库存/财务、不执行SQL或部署;成功失败均按最新版本停用本轮司机,保留样本和报告 `createdResources.fleetDriverQualification`。失败重跑使用新样本,原失败证据不改写;该专项不代替整车实际发车链和浏览器验收。
|
||||
|
||||
`fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。
|
||||
|
||||
`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。
|
||||
`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;TEST/GRAY 执行时先要求两个不同的固定客户及其凭据,通过实际登录核验 ACTIVE 归属和尺寸初始化;不创建客户。重新核对指定实际收货仓和FBA目的仓,经普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物。固定客户允许已有历史库存,完整原始快照逐行验证客户/仓归属后,仅选择本轮来货的余额、位置与流水;再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。
|
||||
|
||||
`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量确认装车,在同一次命令自动生成客户目的地单据并发车;按详情 documents 核对真实单据,原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。
|
||||
|
||||
@@ -198,7 +198,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
|
||||
|
||||
退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。按 `PALLET` 提交每 SKU 箱数和托盘长宽高、重量,总箱数由服务端汇总;主场景验证不上传可选照片仍能完成。边界场景继续验证合成 PNG 上传及合法多 SKU 同托签收,不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。主链合箱前通过PDA箱内清点接口确认本轮两只实收箱的SKU数量和箱规;签收及部分合箱成功后,换新键提交旧数量快照必须被拒绝,且立即回查订单、批次、箱和SKU均未变化。
|
||||
|
||||
`return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:本轮新建第二隔离客户,复用两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 停用新增 PDA,保留测试客户和业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。
|
||||
`return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:复用已配置的第二个固定客户及两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 退出复用 PDA 会话,仅停用本轮新增 PDA,保留业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。
|
||||
|
||||
扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。
|
||||
|
||||
@@ -531,7 +531,7 @@ node --test scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-co
|
||||
|
||||
## 清库后的商户下单前置
|
||||
|
||||
`merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它通过平台正式接口创建本轮隔离客户,经 File 上传真实营业执照和法人身份证文件,再走 IAM 提交审核和平台审核通过;审核完成后把本轮动态加载模块使用的商户账号切换到该客户。
|
||||
`merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它只登录已配置的既有固定客户,核对账号实际归属、ACTIVE 状态及默认尺寸初始化 READY,再供本轮业务模块复用。不包含建客、上传资质或审核代码;任何调用上下文缺少固定客户都在请求前阻断,不按环境回退造客。
|
||||
|
||||
依赖商户下单资格的模块不要单独运行。定向排查时至少保留前置模块,例如:
|
||||
|
||||
@@ -587,3 +587,7 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采
|
||||
- 合同数量与失败清单以同一源码快照的实际报告为准,不在模块索引维护固定失败数量;当前已验/未验范围统一查外层部署契约,历史报告不覆盖最新源码。
|
||||
|
||||
采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。
|
||||
|
||||
`fixed-test-actors.mjs` 是固定测试角色与账号复用助手,不是独立入口。配置 `fixedActors` 按稳定槽位提供 `membershipId`、`username`、`password`、`roleId`、`roleCode`,ID 必须为十进制字符串。成员按权威目录分页并逐行匹配成员 ID 和登录名,不把登录名传作关键词(该接口的关键词只筛姓名、手机号、邮箱和成员号);HTTP 拼车使用 `pool.sales.0/1`、`pool.dispatch.0/1`、`pool.fulfillment.0`,页面样本使用 `pool-ui.SALES_A`、`pool-ui.SALES_B`、`pool-ui.MANAGER`,车队权限探针使用 `fleet-cost.reader/denied`。只读核对 ACTIVE 角色、平台成员及唯一角色绑定,登录后逐项核对实际权限;隔离槽位必须使用不同账号,角色可以按相同权限复用。缺项或权限漂移阻断,不新增或修改角色、账号、权限。结束只退出本轮会话,固定身份不停用,订单继续保留真实创建人。`fixed-test-actors-contract.test.mjs` 覆盖重复运行、权限越界、身份漂移和退出失败。Finance 的 `loadSupplier` 在所有上下文都只查询和复用已启用、类别及船司/拖车范围匹配的供应商,缺失不再创建替代主档。
|
||||
|
||||
固定账号目录核对统一按成员 ID 和登录名读取分页结果,PDA及国内权限拒绝探针同样不把登录名作为服务端关键词。`restoreFixedPicker` 只从当前固定池恢复检查点记录的同一成员及仓绑定;不按批次推算用户名或口令,不启用、重置或新建账号。Admin 根上下文共享 PDA 会话占用集合,子流程的租用可由父流程正常退出释放,避免同一固定操作员被子流程重复占用或遗留登录会话。
|
||||
|
||||
@@ -36,12 +36,12 @@ test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geogr
|
||||
assert.match(supplierFixture, /status:'ACTIVE'/)
|
||||
assert.doesNotMatch(supplierFixture, /scope\s*:/)
|
||||
|
||||
const supplierBody = financeFixture.slice(financeFixture.indexOf('export function buildSupplierBody'),
|
||||
financeFixture.indexOf('async function createOrder'))
|
||||
assert.match(supplierBody, /category,/)
|
||||
assert.match(supplierBody, /status: 'ACTIVE'/)
|
||||
assert.match(supplierBody, /truckingScope: category === 'TRUCKING' \? 'DOMESTIC' : null/)
|
||||
assert.doesNotMatch(supplierBody, /bookingScope|category === 'BOOKING' \? 'DOMESTIC'/)
|
||||
const selector = financeFixture.slice(financeFixture.indexOf('export async function loadSupplier'), financeFixture.indexOf('async function uploadVoucher'))
|
||||
assert.match(selector, /status === 'ACTIVE'/)
|
||||
assert.match(selector, /item.category === category/)
|
||||
assert.match(selector, /item.truckingScope === truckingScope/)
|
||||
assert.doesNotMatch(selector, /bookingScope/)
|
||||
|
||||
})
|
||||
|
||||
test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => {
|
||||
|
||||
@@ -235,7 +235,7 @@ export async function verifyPermissionDenial(context, marker, warehouseId, conta
|
||||
}
|
||||
let user
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100`)
|
||||
const result = context.apiSuccess(MODULE, '查询固定权限拒绝账号', response)
|
||||
user = result?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username)
|
||||
if (user || !result?.records?.length || page * Number(result.size || 100) >= Number(result.total || 0)) break
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run,
|
||||
import { buildContainerCostBody, normalizeFinanceFixtureSources, run,
|
||||
supplierCategoryForFeeItem, loadSupplier, ensureCustomerChargeRule } from './finance-full-fixtures.mjs'
|
||||
|
||||
for (const mode of ['single', 'default', 'missing', 'wrong mode', 'foreign customer', 'ambiguous', 'two defaults']) {
|
||||
@@ -115,15 +115,11 @@ test('PAYABLE fee selection uses only the explicit nullable supplierCategory fie
|
||||
assert.match(source, /payableSupplierCategory && source\.supplierCategory !== payableSupplierCategory/)
|
||||
})
|
||||
|
||||
test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the formal API', () => {
|
||||
const body = buildSupplierBody('ADMIN-API-20260815-FINANCE', 'BOOKING')
|
||||
assert.equal(body.category, 'BOOKING')
|
||||
assert.deepEqual(body.carrierCodes, ['MATSON'])
|
||||
assert.equal(Object.hasOwn(body, 'scope'), false)
|
||||
assert.equal(body.status, 'ACTIVE')
|
||||
assert.match(body.name, /^财务验收供应商-/)
|
||||
assert.match(source, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
|
||||
assert.match(source, /detail\?\.status === 'ACTIVE' && \(!category \|\| detail\?\.category === category\)/)
|
||||
test('Finance supplier helper always reuses existing masters and has no create fallback', () => {
|
||||
const helper = source.slice(source.indexOf('export async function loadSupplier'), source.indexOf('async function uploadVoucher'))
|
||||
assert.ok(!helper.includes("request(context, 'POST'"))
|
||||
assert.ok(helper.includes('禁止自动创建'))
|
||||
assert.ok(!helper.includes('fixedCustomerId'))
|
||||
})
|
||||
|
||||
test('Finance measurement can reuse real pallets from the selected pricing warehouse', () => {
|
||||
|
||||
@@ -32,20 +32,6 @@ export function supplierCategoryForFeeItem(item) {
|
||||
? item.supplierCategory : null
|
||||
}
|
||||
|
||||
export function buildSupplierBody(marker, category) {
|
||||
const suffix = String(marker || '').replace(/[^0-9A-Za-z]/g, '').slice(-24)
|
||||
return {
|
||||
name: `财务验收供应商-${suffix}`,
|
||||
category,
|
||||
truckingScope: category === 'TRUCKING' ? 'DOMESTIC' : null,
|
||||
...(category === 'BOOKING' ? { carrierCodes: ['MATSON'] } : {}),
|
||||
region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人',
|
||||
phone: '13800000000', email: `finance-supplier-${suffix || 'fixture'}@example.com`,
|
||||
address: '财务验收地址', businessLicenseNo: `FIN${suffix}`.slice(0, 64),
|
||||
licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: marker,
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrder(context, suffix, pricingProfile = null) {
|
||||
if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile)
|
||||
const originalMarker = context.marker
|
||||
@@ -260,41 +246,25 @@ async function loadPublishedAutomaticFee(context, resource) {
|
||||
|
||||
export async function loadSupplier(context, category, { carrierCode, truckingScope } = {}) {
|
||||
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const matches = item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category)
|
||||
&& (!carrierCode || item.carrierCodes?.includes(carrierCode)) && (!truckingScope || item.truckingScope === truckingScope)
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`)
|
||||
const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS)
|
||||
if (!data) return null
|
||||
const records = rows(data)
|
||||
const candidate = records.find(matches)
|
||||
if (candidate) {
|
||||
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`)
|
||||
const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS)
|
||||
if (id(detail?.id) === id(candidate.id) && matches(detail)) return detail
|
||||
blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商')
|
||||
return null
|
||||
}
|
||||
if (!records.length || page * Number(data.size || 100) >= Number(data.total ?? records.length)) break
|
||||
const matches = item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category)
|
||||
&& (!carrierCode || item.carrierCodes?.includes(carrierCode)) && (!truckingScope || item.truckingScope === truckingScope)
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`)
|
||||
const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS)
|
||||
if (!data) return null
|
||||
const records = rows(data)
|
||||
const candidate = records.find(matches)
|
||||
if (candidate) {
|
||||
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`)
|
||||
const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS)
|
||||
if (id(detail?.id) === id(candidate.id) && matches(detail)) return detail
|
||||
blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商')
|
||||
return null
|
||||
}
|
||||
blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`)
|
||||
return null
|
||||
if (!records.length || page * Number(data.size || 100) >= Number(data.total ?? records.length)) break
|
||||
}
|
||||
const effectiveCategory = category || 'BOOKING'
|
||||
const body = buildSupplierBody(context.marker, effectiveCategory)
|
||||
const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body)
|
||||
const supplierId = id(context.apiSuccess(MODULE, '创建本轮 Finance 隔离供应商', response,
|
||||
`category=${effectiveCategory}`, OPTIONS))
|
||||
if (!supplierId) return null
|
||||
const detailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`)
|
||||
const detail = context.apiSuccess(MODULE, '回读本轮 Finance 隔离供应商', detailResponse,
|
||||
`supplierId=${supplierId}`, OPTIONS)
|
||||
context.assert(MODULE, 'Finance 供应商为 ACTIVE 且类别匹配费用项目', detailResponse,
|
||||
id(detail?.id) === supplierId && detail?.status === 'ACTIVE' && (!category || detail?.category === category)
|
||||
&& detail?.name === body.name,
|
||||
`supplierId=${supplierId}, status=${detail?.status || 'missing'}, category=${detail?.category || 'missing'}`, OPTIONS)
|
||||
return detail?.status === 'ACTIVE' && (!category || detail?.category === category) ? detail : null
|
||||
blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`)
|
||||
return null
|
||||
}
|
||||
|
||||
async function uploadVoucher(context, customerId) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { openFixedActor, requireFixedActors } from './fixed-test-actors.mjs'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const calls = [], actor = { membershipId: '101', roleId: '201', roleCode: 'EXISTING_READER', username: 'existing-reader', password: 'private-fixed-password' }
|
||||
const context = { token: 'admin', options: { fixedActors: { reader: actor } }, async request(method, path, body, token) {
|
||||
calls.push({ method, path, body, token })
|
||||
let data
|
||||
if (path.endsWith('/roles')) data = [{ id: '201', roleCode: actor.roleCode, status: fault === 'inactive role' ? 'DISABLED' : 'ACTIVE' }]
|
||||
else if (path.includes('/users?')) {
|
||||
const keyword = new URL(path, 'https://fixture.invalid').searchParams.get('keyword')
|
||||
// The service does not search login names; its keyword filters profile fields/member numbers.
|
||||
assert.equal(keyword, null)
|
||||
data = { page: 1, size: 100, total: 1, records: [{ membershipId: '101', username: actor.username,
|
||||
tenantId: fault === 'foreign user' ? '9' : '0', status: fault === 'inactive user' ? 'DISABLED' : 'ACTIVE', roleIds: fault === 'extra role' ? ['201', '999'] : ['201'] }] }
|
||||
}
|
||||
else if (path.endsWith('/login')) data = { accessToken: 'reader-session', refreshToken: 'reader-refresh' }
|
||||
else if (path.endsWith('/authorization/permissions')) data = fault === 'extra permissions' ? ['read', 'write'] : ['read']
|
||||
else if (path.endsWith('/logout')) return { http: 200, body: { code: fault === 'logout fails' ? 'S0503' : '200' } }
|
||||
else assert.fail(path)
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
return { context, calls, actor }
|
||||
}
|
||||
|
||||
test('repeated runs reuse the same configured membership and role, and logout is idempotent', async () => {
|
||||
const { context, calls } = fixture()
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const actor = await openFixedActor(context, 'reader', ['read'])
|
||||
assert.equal(actor.membershipId, '101'); assert.equal(actor.roleId, '201'); assert.equal(actor.reused, true)
|
||||
assert.equal(actor.password, undefined)
|
||||
await actor.close(); const count = calls.length; await actor.close(); assert.equal(calls.length, count)
|
||||
}
|
||||
assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path)))
|
||||
assert.equal(calls.filter(call => call.path.endsWith('/logout')).length, 3)
|
||||
})
|
||||
|
||||
for (const fault of ['inactive role', 'foreign user', 'inactive user', 'extra role', 'extra permissions']) test(`fixed actor rejects ${fault} without changing resources`, async () => {
|
||||
const { context, calls } = fixture(fault)
|
||||
await assert.rejects(openFixedActor(context, 'reader', ['read']), /不匹配/)
|
||||
assert.equal(calls.some(call => ['PUT', 'PATCH', 'DELETE'].includes(call.method)), false)
|
||||
assert.equal(calls.some(call => call.method === 'POST' && !call.path.startsWith('/api/auth/')), false)
|
||||
if (fault === 'extra permissions') assert.equal(calls.at(-1).path, '/api/auth/logout')
|
||||
})
|
||||
|
||||
test('missing configuration or reusing one account for two isolated actors stops before HTTP', async () => {
|
||||
const { context, actor, calls } = fixture()
|
||||
await assert.rejects(openFixedActor(context, 'missing', []), /禁止自动创建/)
|
||||
assert.throws(() => requireFixedActors({ fixedActors: { a: actor, b: actor } }, ['a', 'b']), /不同的固定账号/)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('membership lookup finds a configured user on later pages without treating the login name as a searchable profile field', async () => {
|
||||
const { context, calls } = fixture(), original = context.request
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (path.includes('/users?')) {
|
||||
const url = new URL(path, 'https://fixture.invalid')
|
||||
assert.equal(url.searchParams.has('keyword'), false)
|
||||
if (url.searchParams.get('page') === '1') return { http: 200, body: { code: '200', data: {
|
||||
page: 1, size: 100, total: 101, records: Array.from({ length: 100 }, (_, index) => ({ membershipId: String(1000 + index), username: 'unrelated' })) } } }
|
||||
const response = await original(method, path, ...args)
|
||||
response.body.data.page = 2; response.body.data.total = 101
|
||||
return response
|
||||
}
|
||||
return original(method, path, ...args)
|
||||
}
|
||||
const actor = await openFixedActor(context, 'reader', ['read'])
|
||||
assert.equal(actor.membershipId, '101'); await actor.close()
|
||||
assert.equal(calls.some(call => call.path.includes('page=2')), true)
|
||||
})
|
||||
|
||||
test('logout failures remain visible and can be checked again without creating accounts', async () => {
|
||||
const { context, calls } = fixture('logout fails')
|
||||
const actor = await openFixedActor(context, 'reader', ['read'])
|
||||
await assert.rejects(actor.close(), /退出未确认/)
|
||||
assert.equal(calls.filter(call => call.path.endsWith('/logout')).length, 1)
|
||||
})
|
||||
|
||||
test('all Admin HTTP transports reject creation of roles, users, permissions and suppliers regardless of environment', async () => {
|
||||
for (const environment of [undefined, 'test', 'dev', 'prod']) {
|
||||
const context = new AcceptanceRun({ environment })
|
||||
for (const path of ['/api/platform/v1/iam/roles', '/api/platform/v1/iam/users', '/api/platform/v1/iam/permissions', '/api/platform/v1/suppliers?tenantId=0'])
|
||||
for (const entry of ['request', 'requestWithBody', 'requestMultipart'])
|
||||
await assert.rejects(context[entry]('POST', path, new FormData()), /禁止新增角色/)
|
||||
}
|
||||
})
|
||||
|
||||
test('local transport and F33 handoff have no master provisioning paths', () => {
|
||||
for (const file of ['local-transfer-pickup-acceptance.mjs', 'local-dispatch-transport-acceptance.mjs', 'fba-remaining-nodes-acceptance-pda-operator.mjs']) {
|
||||
const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(source, /['"]POST['"]\s*,\s*['"](?:\/api)?\/platform\/v1\/iam\/(roles|users|permissions)['"]/, file)
|
||||
assert.doesNotMatch(source, /['"]PUT['"]\s*,\s*['"](?:\/api)?\/platform\/v1\/iam\/roles\/permissions['"]/, file)
|
||||
}
|
||||
})
|
||||
|
||||
test('report security scans fixed actor passwords even if hidden under ordinary evidence keys', async () => {
|
||||
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true, modules: [], fixedActors: { reader: { password: 'fixed-sensitive-password' } } })
|
||||
context.request = async () => ({ http: 200, body: { code: 'A0401' } })
|
||||
context.createdResources.evidence = { note: 'fixed-sensitive-password' }
|
||||
const report = await context.run()
|
||||
assert.equal(report.steps.find(step => step.module === 'report-security').passed, false)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
const IAM = '/api/platform/v1/iam'
|
||||
const id = value => String(value ?? '')
|
||||
const same = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort())
|
||||
|
||||
export const POOL_ACTOR_SLOTS = ['pool.sales.0', 'pool.sales.1', 'pool.dispatch.0', 'pool.dispatch.1', 'pool.fulfillment.0']
|
||||
export const UI_ACTOR_SLOTS = ['pool-ui.SALES_A', 'pool-ui.SALES_B', 'pool-ui.MANAGER']
|
||||
export const COST_ACTOR_SLOTS = ['fleet-cost.reader', 'fleet-cost.denied']
|
||||
|
||||
export function requireFixedActors(options, slots) {
|
||||
const actors = options?.fixedActors
|
||||
const users = new Set(), memberships = new Set()
|
||||
for (const slot of slots) {
|
||||
const actor = actors?.[slot]
|
||||
if (!actor || !['membershipId', 'roleId'].every(key => typeof actor[key] === 'string' && /^[1-9]\d*$/.test(actor[key]))
|
||||
|| !['username', 'password', 'roleCode'].every(key => typeof actor[key] === 'string' && actor[key].trim()))
|
||||
throw Error(`固定测试账号 ${slot} 配置不完整;禁止自动创建角色或账号`)
|
||||
if (users.has(actor.username) || memberships.has(actor.membershipId))
|
||||
throw Error('隔离场景必须使用不同的固定账号,不能以同一账号代替')
|
||||
users.add(actor.username); memberships.add(actor.membershipId)
|
||||
}
|
||||
return actors
|
||||
}
|
||||
|
||||
/** Only validates existing platform identities and opens/closes its own sessions. Never changes roles or accounts. */
|
||||
export async function openFixedActor(context, slot, expectedCodes) {
|
||||
const actor = requireFixedActors(context.options, [slot])[slot]
|
||||
async function request(method, path, body, token = context.token) {
|
||||
const response = await context.request(method, path, body, token)
|
||||
if (response?.http !== 200 || String(response.body?.code) !== '200') throw Error(`固定账号 ${slot} 核验失败:${method} ${path.split('?')[0]}`)
|
||||
return response.body.data
|
||||
}
|
||||
const roles = await request('GET', `${IAM}/roles`)
|
||||
const matches = Array.isArray(roles) ? roles.filter(row => id(row.id) === actor.roleId) : []
|
||||
if (matches.length !== 1 || matches[0].roleCode !== actor.roleCode || matches[0].status !== 'ACTIVE')
|
||||
throw Error(`固定账号 ${slot} 的角色身份或启用状态不匹配,不自动改角色`)
|
||||
let user, total
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
// IAM keyword filters profile fields/member numbers, not the login name. Match the configured identity in the authoritative directory.
|
||||
const data = await request('GET', `${IAM}/users?${new URLSearchParams({ page: String(page), size: '100' })}`)
|
||||
if (!Array.isArray(data?.records) || Number(data.page) !== page || Number(data.size) !== 100
|
||||
|| !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 || data.records.length > 100
|
||||
|| (total !== undefined && total !== Number(data.total))) throw Error('固定账号查询分页不完整或发生变化')
|
||||
total ??= Number(data.total)
|
||||
const candidates = data.records.filter(row => id(row.membershipId) === actor.membershipId && row.username === actor.username)
|
||||
if (candidates.length > 1) throw Error('固定账号查询身份重复')
|
||||
if (candidates.length) { user = candidates[0]; break }
|
||||
if (page * 100 >= total) break
|
||||
if (!data.records.length) throw Error('固定账号查询分页缺页')
|
||||
}
|
||||
if (!user || user.status !== 'ACTIVE' || id(user.tenantId) !== '0'
|
||||
|| !Array.isArray(user.roleIds) || !same(user.roleIds.map(id), [actor.roleId]))
|
||||
throw Error(`固定账号 ${slot} 的成员、平台归属、状态或角色绑定不匹配,不自动修改账号`)
|
||||
const session = await request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '')
|
||||
let closed = false
|
||||
async function close() {
|
||||
if (closed) return
|
||||
if (!session?.accessToken || !session.refreshToken) throw Error('固定账号会话不完整,无法确认退出')
|
||||
const response = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken)
|
||||
if (!(response?.http === 200 && String(response.body?.code) === '200')
|
||||
&& !([200, 401].includes(response?.http) && String(response.body?.code) === 'A0401')) throw Error('固定账号退出未确认,保留现场')
|
||||
closed = true
|
||||
}
|
||||
try {
|
||||
if (!session?.accessToken || !session.refreshToken || session.accessToken === context.token) throw Error('固定账号缺少独立完整会话')
|
||||
const codes = await request('GET', `${IAM}/authorization/permissions`, undefined, session.accessToken)
|
||||
if (!Array.isArray(codes) || !same(codes, expectedCodes)) throw Error(`固定账号 ${slot} 实际权限不匹配,不自动授权或扩大权限`)
|
||||
} catch (error) {
|
||||
try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], '固定账号核验和退出均失败') }
|
||||
throw error
|
||||
}
|
||||
return { membershipId: actor.membershipId, username: actor.username, roleId: actor.roleId, roleCode: actor.roleCode,
|
||||
token: session.accessToken, refreshToken: session.refreshToken, reused: true, close }
|
||||
}
|
||||
@@ -12,28 +12,22 @@ function fixture(fault) {
|
||||
const history = row => ({ recordVersion: row.cost.recordVersion, action: row.action, snapshot: copy(row.cost) })
|
||||
function save(row, action) { row.action = action; row.history.unshift(history(row)) }
|
||||
function current(row) { const { cost } = row; return copy(cost) }
|
||||
let supplier
|
||||
const context = { marker, token: 'offline-test-only', createdResources: {},
|
||||
const supplier = { id: '8', tenantId: fault === 'foreign supplier' ? '9' : '0', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: fault === 'inactive supplier' ? 'INACTIVE' : 'ACTIVE' }
|
||||
vehicles.set('71', { id: '71', plateNumber: 'EXISTING-TEST', region: 'OVERSEAS', ownership: fault === 'external vehicle' ? 'EXTERNAL' : 'OWNED', status: fault === 'inactive vehicle' ? 'INACTIVE' : 'IN_SERVICE', recordVersion: 0 })
|
||||
const context = { marker, token: 'offline-test-only', createdResources: {}, options: { fixedCompanyCostResources: { supplierId: '8', vehicleId: '71' } },
|
||||
assert(_module, label, _response, condition) { assert.ok(condition, label) },
|
||||
apiSuccess(_module, label, result) { assert.equal(result?.http, 200, label); assert.equal(result?.body?.code, '200', label); return result.body.data },
|
||||
async request(method, raw, body, _token, headers) {
|
||||
calls.push({ method, raw, body: copy(body), headers: copy(headers) }); const url = new URL(raw, 'https://fixture.invalid'), path = url.pathname
|
||||
if (method === 'POST' && path === '/api/platform/v1/suppliers') { supplier = { id: '8', ...copy(body) }; return response('8') }
|
||||
if (method === 'GET' && path === '/api/platform/v1/suppliers/8') return response(supplier)
|
||||
if (method === 'GET' && path.includes('/master-data/exchange-rates/')) return response({ month: path.split('/').at(-1), status: fault === 'inactive exchange' ? 'INACTIVE' : 'ACTIVE', usdToCnyAverage: '7.123456', recordVersion: 0 })
|
||||
if (path.endsWith('/fee-pool')) return response(fault === 'customer pollution' && costs.size ? [{ poolItemId: 'LEAK', customerId: '9', amount: 27.31, feeItemName: marker }] : [])
|
||||
if (path.endsWith('/cost-entries')) return response([])
|
||||
if (path.endsWith('/customer-accounts/9/summary')) return response({ customerId: '9', currency: 'CNY', totalReceivable: 100, receivedAmount: 30, outstandingReceivable: 70,
|
||||
pendingBilling: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, asOf: new Date().toISOString() })
|
||||
if (path === '/api/platform/v1/last-leg/master-data/vehicles' && method === 'POST') {
|
||||
const row = { id: '71', ...copy(body), recordVersion: 0 }; vehicles.set(row.id, row); return response(row)
|
||||
}
|
||||
if (path.startsWith('/api/platform/v1/last-leg/master-data/vehicles/')) {
|
||||
const row = vehicles.get(path.split('/vehicles/')[1].split('/')[0]); assert.ok(row)
|
||||
if (method === 'GET') return response(row)
|
||||
assert.equal(method, 'PATCH'); assert.equal(body.status, 'INACTIVE')
|
||||
if (fault === 'cleanup failure') return response(null, 'S0503', 503)
|
||||
row.status = 'INACTIVE'; row.recordVersion++; return response(row)
|
||||
assert.equal(method, 'GET', 'shared vehicle masters must remain read-only'); return response(row)
|
||||
}
|
||||
const care = path.match(/^\/api\/platform\/v1\/last-leg\/fleet\/vehicles\/71\/records(?:\/(.*))?$/)
|
||||
if (care) {
|
||||
@@ -118,28 +112,29 @@ function fixture(fault) {
|
||||
const id = value => String(value)
|
||||
const options = { pollAttempts: 2, pollDelayMs: 0 }
|
||||
test('independent runner requires explicit service readiness and only the specified TEST profile', () => {
|
||||
const env = 'C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'
|
||||
const env = 'C:/Users/BY-OY/.codex/env/chongchong-global/test/acceptance.env'
|
||||
assert.throws(() => requireReady([], [`--env-file=${env}`]), /就绪/)
|
||||
assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('test-acceptance', 'dev-acceptance')}`]), /授权目录/)
|
||||
assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).endsWith('test-acceptance.env'))
|
||||
assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('/test/', '/dev/')}`]), /授权目录/)
|
||||
assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).replaceAll('\\', '/').endsWith('/test/acceptance.env'))
|
||||
})
|
||||
|
||||
test('readiness gate accepts only the full production anchor, not old anchors, shorthand or another commit', () => {
|
||||
const profile = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env']
|
||||
const profile = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test/acceptance.env']
|
||||
assert.equal(READY_COMMIT, '8700fc34238bc33339351c73c15f3af8de8ce517')
|
||||
for (const commit of ['a6fd0ba3', 'cb6c54ff', '8700fc34', 'd05ff1cd', `${READY_COMMIT}0`]) {
|
||||
assert.throws(() => requireReady([`--ready-commit=${commit}`], profile), /就绪/)
|
||||
}
|
||||
assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], profile).endsWith('test-acceptance.env'))
|
||||
assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], profile).replaceAll('\\', '/').endsWith('/test/acceptance.env'))
|
||||
})
|
||||
test('company cost verifier exercises CNY/USD, void/rebuild, payment replay and customer isolation without direct transport', async () => {
|
||||
const { context, calls, vehicles, records, costs } = fixture()
|
||||
await run(context, options)
|
||||
const proof = context.createdResources.fleetCompanyCostAcceptance
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.cleanup, 'INACTIVE')
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.cleanup, 'REUSED_UNCHANGED'); assert.equal(proof.reusedMasterData, true)
|
||||
assert.equal(proof.cases.length, 2); assert.equal(records.size, 2)
|
||||
assert.ok([...costs.values()].every(row => row.cost.status === 'PAID' && row.payments.length === 2))
|
||||
assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE'))
|
||||
assert.ok([...vehicles.values()].every(row => row.status === 'IN_SERVICE' && row.recordVersion === 0))
|
||||
assert.equal(calls.some(call => call.method !== 'GET' && /suppliers|master-data\/vehicles/.test(call.raw)), false)
|
||||
assert.ok(calls.every(call => call.raw.startsWith('/api/platform/')))
|
||||
assert.equal(calls.some(call => /cost-entries|customer-accounts|customerId=/.test(call.raw)), false, 'must not borrow another task customer for evidence')
|
||||
assert.equal(calls.some(call => call.method === 'DELETE'), false)
|
||||
@@ -149,15 +144,21 @@ test('missing independent marker blocks before any write', async () => {
|
||||
const { context, calls } = fixture(); context.marker = ''
|
||||
await assert.rejects(run(context, options), /独立marker/); assert.equal(calls.length, 0)
|
||||
})
|
||||
for (const resources of [undefined, {}, { supplierId: '8' }, { supplierId: '8', vehicleId: '0' }, { supplierId: 8, vehicleId: 71 }]) test(`missing fixed company masters stop before HTTP: ${JSON.stringify(resources)}`, async () => {
|
||||
const { context, calls } = fixture(); context.options.fixedCompanyCostResources = resources
|
||||
await assert.rejects(run(context, options), /禁止自动创建主档/)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
for (const fault of ['inactive exchange', 'delivery exhausted', 'never delivered', 'wrong source vehicle', 'wrong source version', 'wrong conversion', 'premature paid',
|
||||
'duplicate payment replay', 'overpayment allowed', 'partial money drift', 'paid void allowed', 'paid source mutable', 'customer pollution', 'file outage', 'wrong file bytes', 'cleanup failure']) {
|
||||
'duplicate payment replay', 'overpayment allowed', 'partial money drift', 'paid void allowed', 'paid source mutable', 'customer pollution', 'file outage', 'wrong file bytes',
|
||||
'foreign supplier', 'inactive supplier', 'external vehicle', 'inactive vehicle']) {
|
||||
test(`company cost verifier rejects ${fault} and preserves failure evidence`, async () => {
|
||||
const { context, vehicles } = fixture(fault)
|
||||
const { context, vehicles, calls } = fixture(fault), before = copy([...vehicles.values()])
|
||||
await assert.rejects(run(context, options))
|
||||
const proof = context.createdResources.fleetCompanyCostAcceptance
|
||||
assert.equal(proof.status, 'FAILED')
|
||||
assert.ok(proof.error || proof.cleanupError)
|
||||
if (vehicles.size && fault !== 'cleanup failure') assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE'))
|
||||
if (fault === 'cleanup failure') assert.equal(proof.cleanup, 'FAILED')
|
||||
assert.deepEqual([...vehicles.values()], before)
|
||||
assert.equal(calls.some(call => call.method !== 'GET' && /suppliers|master-data\/vehicles/.test(call.raw)), false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical
|
||||
/** Internal authorized TEST module; entrypoint owns registration, credentials and execution. No direct fetch, SQL or historical fixtures. */
|
||||
export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = {}) {
|
||||
if (!context.marker || !context.createdResources) throw new Error('公司车辆成本验收须有独立marker和本轮资源上下文')
|
||||
const resources = context.options?.fixedCompanyCostResources
|
||||
if (!resources || !['supplierId', 'vehicleId'].every(key => typeof resources[key] === 'string' && /^[1-9]\d*$/.test(resources[key])))
|
||||
throw new Error('公司成本验收必须配置既有供应商和车辆,禁止自动创建主档')
|
||||
if (context.createdResources.fleetCompanyCostAcceptance) throw new Error('本轮已存在公司成本验收记录;保留原证据,不自动重新造数')
|
||||
if (!Number.isInteger(pollAttempts) || pollAttempts < 1 || pollAttempts > 120 || !Number.isFinite(pollDelayMs) || pollDelayMs < 0 || pollDelayMs > 5000)
|
||||
throw new Error('公司成本轮询范围无效')
|
||||
@@ -73,17 +76,15 @@ export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = {
|
||||
proof.exchangeRate = { month, usdToCnyAverage: id(exchange.usdToCnyAverage), recordVersion: id(exchange.recordVersion) }
|
||||
proof.customerBefore = await snapshotCustomer('操作前')
|
||||
check('本轮marker尚无客户费用污染,禁止覆盖旧验收', proof.customerBefore.companyFeePoolRows === 0)
|
||||
proof.supplierRequest = { name: `${context.marker} 公司成本专用供应商`, category: 'TRUCKING', carrierCodes: [], region: 'TEST-OVERSEAS',
|
||||
legalRepresentative: '受控验收', contactName: '受控验收', phone: '00000000000', email: null, address: '受控测试地点',
|
||||
businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'IMMEDIATE', remark: context.marker, truckingScope: 'OVERSEAS' }
|
||||
proof.supplierId = id(await request('POST', '/api/platform/v1/suppliers?tenantId=0', proof.supplierRequest, '创建本轮独占公司成本供应商'))
|
||||
check('供应商创建返回真实标识', /^[1-9]\d*$/.test(proof.supplierId))
|
||||
const supplier = await request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(proof.supplierId)}?tenantId=0`, undefined, '核对本轮独占供应商')
|
||||
check('供应商确属本轮独占样本', id(supplier.id) === proof.supplierId && supplier.status === 'ACTIVE' && supplier.remark === context.marker && supplier.name === proof.supplierRequest.name)
|
||||
const vehicle = await request('POST', `${MASTER}/vehicles`, { plateNumber: `FC-${randomUUID().slice(0, 12)}`, model: 'Company cost TEST', fuelConsumptionPer100Km: 10,
|
||||
mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '创建本轮公司成本专用车辆')
|
||||
check('车辆返回稳定身份', Boolean(vehicle.id && vehicle.plateNumber))
|
||||
proof.vehicleId = id(vehicle.id); proof.plateNumber = vehicle.plateNumber; proof.cleanup = 'PENDING'
|
||||
proof.supplierId = id(resources.supplierId)
|
||||
const supplier = await request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(proof.supplierId)}?tenantId=0`, undefined, '核对已配置的既有供应商')
|
||||
check('复用供应商为平台启用海外拖车供应商', id(supplier.id) === proof.supplierId && id(supplier.tenantId) === '0'
|
||||
&& supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS')
|
||||
const vehicle = await request('GET', `${MASTER}/vehicles/${encodeURIComponent(id(resources.vehicleId))}`, undefined, '核对已配置的既有公司车辆')
|
||||
check('复用车辆为海外自有在役车辆', id(vehicle.id) === id(resources.vehicleId) && Boolean(vehicle.plateNumber)
|
||||
&& vehicle.region === 'OVERSEAS' && vehicle.ownership === 'OWNED' && vehicle.status === 'IN_SERVICE')
|
||||
proof.vehicleId = id(vehicle.id); proof.plateNumber = vehicle.plateNumber
|
||||
proof.reusedMasterData = true; proof.cleanup = 'REUSED_UNCHANGED'
|
||||
const care = `/api/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(proof.vehicleId)}/records`
|
||||
for (const [recordType, currency, originalAmount] of [['REPAIR', 'CNY', 27.31], ['RESCUE', 'USD', 10]]) {
|
||||
const scenario = { recordType, status: 'CHECKING', commands: [] }; proof.cases.push(scenario)
|
||||
@@ -188,15 +189,5 @@ export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = {
|
||||
}
|
||||
proof.status = 'VERIFIED'
|
||||
} catch (error) { failure = error; proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error) }
|
||||
finally {
|
||||
if (proof.vehicleId) {
|
||||
try {
|
||||
const path = `${MASTER}/vehicles/${encodeURIComponent(proof.vehicleId)}`
|
||||
const vehicle = await request('GET', path, undefined, '结束前读取本轮专用车辆')
|
||||
const ended = await request('PATCH', `${path}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮专用车辆,保留财务与运维证据')
|
||||
check('本轮专用车辆已停用', ended.status === 'INACTIVE'); proof.cleanup = 'INACTIVE'
|
||||
} catch (error) { proof.cleanup = 'FAILED'; proof.cleanupError = error instanceof Error ? error.message : String(error); proof.status = 'FAILED'; failure ??= error }
|
||||
}
|
||||
}
|
||||
if (failure) throw failure
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
|
||||
|
||||
// Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too.
|
||||
function model(fault = '', countPatch = {}) {
|
||||
const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = []
|
||||
const copy = value => structuredClone(value), calls = [], customers = [{ id: '100', username: 'existing-a', password: 'offline-a' }, { id: '101', username: 'existing-b', password: 'offline-b' }], transfers = [], dispatches = []
|
||||
const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' }
|
||||
const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30,
|
||||
vehiclePlate: 'TEST', driverName: 'TEST', driverPhone: '19900000000' }, dispatches: [] }
|
||||
@@ -28,7 +28,7 @@ function model(fault = '', countPatch = {}) {
|
||||
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0,
|
||||
destination: { key: `FBA:${transfer.destinationWarehouseCode}` } }))
|
||||
const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' }
|
||||
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture },
|
||||
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer', fixedCustomerId: '100', merchantUsername: 'existing-a', merchantPassword: 'offline-a', fixedSecondaryCustomer: { customerId: '101', username: 'existing-b', password: 'offline-b' }, fixedFleetResources: [{ vehicleId: '11', driverId: '12' }] }, context: { merchantQualificationFixture: parentFixture },
|
||||
createdResources: { merchantQualificationFixture: { marker: 'PARENT' } },
|
||||
assert(_module, label, _response, passed) { assert.ok(passed, label) },
|
||||
apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data },
|
||||
@@ -59,17 +59,7 @@ function model(fault = '', countPatch = {}) {
|
||||
if (path.endsWith('/warehouse-admin/20')) return response(destination)
|
||||
if (path.endsWith('/warehouse-admin/21')) return response({ ...destination,
|
||||
warehouse: { ...destination.warehouse, id: '21', warehouseCode: 'FBA2', warehouseName: 'Second destination' } })
|
||||
if (path === '/api/platform/v1/iam/tenants') {
|
||||
const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n)
|
||||
const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark }
|
||||
customers.push(customer)
|
||||
return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password })
|
||||
}
|
||||
if (path.endsWith('/submit-review') && path.includes('/iam/')) return response({ status: 'PENDING' })
|
||||
if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id })))
|
||||
if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' })
|
||||
if (path.endsWith('/approve')) return response({})
|
||||
if (path.endsWith('/qualifications')) return response(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' })))
|
||||
if (path === '/api/auth/login') {
|
||||
assert.equal(token, '')
|
||||
const customer = customers.find(row => row.username === body.loginId); assert.equal(body.password, customer.password)
|
||||
@@ -81,7 +71,7 @@ function model(fault = '', countPatch = {}) {
|
||||
return response(null)
|
||||
}
|
||||
const merchant = customers.find(row => token === `merchant-${row.id}-secret`)
|
||||
if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id })
|
||||
if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : fault === 'reused customer' ? '100' : merchant.id, status: 'ACTIVE', tenantName: 'Existing fixture customer' })
|
||||
if (path === '/api/merchant/v1/last-leg/warehouse-transfers') {
|
||||
assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, [])
|
||||
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1); assert.equal(body.items[0].quantity, 1)
|
||||
@@ -164,9 +154,9 @@ function model(fault = '', countPatch = {}) {
|
||||
.map(code => ({ name: code, destinationKey: `FBA:${code}` }))] })
|
||||
dispatches.push(native); return response(native)
|
||||
}
|
||||
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber.toUpperCase(); return response({ id: 'VEHICLE' }) }
|
||||
if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' })
|
||||
if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' })
|
||||
if (path.endsWith('/master-data/vehicles/11')) return response({ id: '11', plateNumber: 'TEST', status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
|
||||
if (path.endsWith('/fleet/drivers/12')) return response({ id: '12', active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
|
||||
if (raw.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return response({ records: trip.trip.vehicleId ? [trip.trip] : [], total: trip.trip.vehicleId ? 1 : 0, size: 200, page: 1 })
|
||||
if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 })
|
||||
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({ dispatchNo: native.dispatchNo, recordVersion: native.recordVersion,
|
||||
@@ -178,7 +168,7 @@ function model(fault = '', countPatch = {}) {
|
||||
assert.deepEqual(body.driver.evidence.photoFileIds, ['902'])
|
||||
assert.deepEqual(body.driver.evidence.fileIds, ['903'])
|
||||
Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId })
|
||||
native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } }
|
||||
native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: '11', plate: trip.trip.vehiclePlate }, driver: { driverId: '12' } }
|
||||
native.recordVersion++
|
||||
} else if (method === 'POST') {
|
||||
assert.equal(body.recordVersion, native.recordVersion); native.recordVersion++
|
||||
@@ -222,7 +212,7 @@ test('real helpers build independent current-run customers, receipts and one com
|
||||
assert.deepEqual(m.context.createdResources.merchantQualificationFixture, { marker: 'PARENT' })
|
||||
assert.equal(m.context.options.customerName, 'Parent customer')
|
||||
assert.deepEqual({ username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, oldEnvironment)
|
||||
assert.notEqual(m.customers[0].marker, m.customers[1].marker)
|
||||
assert.equal(m.calls.some(row => row.raw === '/api/platform/v1/iam/tenants' || row.raw === '/api/auth/register'), false)
|
||||
const proof = m.context.createdResources.fleetCrossCustomerFixture
|
||||
assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
|
||||
assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
|
||||
@@ -246,6 +236,32 @@ test('real helpers build independent current-run customers, receipts and one com
|
||||
for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret))
|
||||
})
|
||||
|
||||
for (const history of [false, true]) test(`GRAY preparation reuses two customers and preserves the parent slot: history=${history}`, async () => {
|
||||
const m = model()
|
||||
Object.assign(m.context.options, { environment: 'gray', fixedCustomerId: 'TEN100', merchantUsername: 'existing-a', merchantPassword: 'offline-a',
|
||||
fixedSecondaryCustomer: { customerId: 'TEN101', username: 'existing-b', password: 'offline-b' }, fixedFleetResources: [] })
|
||||
if (history) {
|
||||
const read = m.options.readInventory
|
||||
m.options.readInventory = async scope => {
|
||||
const raw = await read(scope), balance = structuredClone(raw.balances[0]), position = structuredClone(raw.positions[0]), ledger = structuredClone(raw.ledger[0])
|
||||
Object.assign(balance, { id: 'HIST-B', inboundCode: 'OLD-RECEIPT' })
|
||||
Object.assign(position, { id: 'HIST-P', balanceId: 'HIST-B', palletId: 'HIST-PALLET', palletNo: 'HIST-PALLET' })
|
||||
Object.assign(ledger, { id: 'HIST-L', balanceId: 'HIST-B', positionId: 'HIST-P', inboundCode: 'OLD-RECEIPT', businessOrderNo: 'OLD-RECEIPT' })
|
||||
raw.balances.push(balance); raw.positions.push(position); raw.ledger.push(ledger)
|
||||
return raw
|
||||
}
|
||||
}
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /固定车辆司机组合/)
|
||||
const proof = m.context.createdResources.fleetCrossCustomerFixture
|
||||
assert.deepEqual(proof.members.map(row => row.customerId), ['100', '101'])
|
||||
assert.ok(proof.members.every(row => row.qualification.reused === true && row.receiptVerified === true))
|
||||
assert.equal(m.customers.length, 2)
|
||||
assert.equal(m.calls.some(row => row.raw === '/api/platform/v1/iam/tenants' || row.raw === '/api/auth/register'), false)
|
||||
assert.equal(m.context.context.merchantQualificationFixture, m.parentFixture)
|
||||
assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
|
||||
if (history) assert.ok(proof.members.every(row => row.receipt.raw.balances.length === 2))
|
||||
})
|
||||
|
||||
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
||||
test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => {
|
||||
const m = model(fault)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { run as createQualifiedMerchant } from './merchant-qualification-fixture.mjs'
|
||||
import { run as createQualifiedMerchant, normalizeFixedCustomerId, requireFixedSecondaryCustomer } from './merchant-qualification-fixture.mjs'
|
||||
import { prepareFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
|
||||
|
||||
@@ -27,7 +27,7 @@ const localTime = delay => {
|
||||
export const preparationContract = Object.freeze({
|
||||
stage: fixtureContract.stage,
|
||||
inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope',
|
||||
writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and a TEST vehicle/qualified driver; both reviews approved. No picking or delivery arrangement yet.',
|
||||
writes: 'TEST/GRAY reuse two designated existing ACTIVE customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and configured reusable vehicle/driver; both reviews approved. No picking or delivery arrangement yet.',
|
||||
lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.',
|
||||
})
|
||||
|
||||
@@ -35,6 +35,8 @@ export const preparationContract = Object.freeze({
|
||||
export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, destinationWarehouseIds,
|
||||
shipmentReferences, prepareBatchAllocations, pdaToken, readInventory } = {}) {
|
||||
if (!execute) return preparationContract
|
||||
if (!context?.options?.fixedCustomerId) throw Error('跨客户验收必须配置两个既有固定客户,禁止自动建客')
|
||||
requireFixedSecondaryCustomer(context.options)
|
||||
if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function'
|
||||
|| ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function'))
|
||||
throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader')
|
||||
@@ -95,11 +97,15 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Raw receipt reader proves complete current customer and warehouse scope', raw?.scope?.marker === scope.marker
|
||||
&& raw.scope.customerId === scope.customerId && raw.scope.warehouseId === scope.warehouseId
|
||||
&& raw.scope.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE')
|
||||
for (const collection of ['balances', 'positions', 'ledger']) check(`Fresh customer raw ${collection} has exactly one original receipt row`,
|
||||
Array.isArray(raw[collection]) && raw[collection].length === 1 && id(raw[collection][0].id)
|
||||
&& id(raw[collection][0].customerId) === member.customerId && id(raw[collection][0].overseasWarehouseId) === id(warehouseId)
|
||||
&& (collection === 'positions' || raw[collection][0].inboundCode === member.incomingNo))
|
||||
const rawBalance = raw.balances[0], rawPosition = raw.positions[0], rawMovement = raw.ledger[0]
|
||||
// 固定客户可有历史库存;完整快照逐行核对归属,本轮只选原入仓码及其实际余额/位置。
|
||||
for (const collection of ['balances', 'positions', 'ledger']) check(`Complete customer raw ${collection} preserves scoped unique identities`,
|
||||
Array.isArray(raw[collection]) && new Set(raw[collection].map(row => id(row.id))).size === raw[collection].length
|
||||
&& raw[collection].every(row => id(row.id) && id(row.customerId) === member.customerId && id(row.overseasWarehouseId) === id(warehouseId)))
|
||||
const currentBalances = raw.balances.filter(row => row.inboundCode === member.incomingNo)
|
||||
const currentPositions = raw.positions.filter(row => currentBalances.some(balance => id(balance.id) === id(row.balanceId)))
|
||||
const currentMovements = raw.ledger.filter(row => row.inboundCode === member.incomingNo && row.businessOrderNo === member.incomingNo)
|
||||
check('Current receipt has exactly one original raw balance position and movement', currentBalances.length === 1 && currentPositions.length === 1 && currentMovements.length === 1)
|
||||
const rawBalance = currentBalances[0], rawPosition = currentPositions[0], rawMovement = currentMovements[0]
|
||||
check('Raw receipt identities and quantities match the public evidence', id(rawBalance.id) === id(balance.id)
|
||||
&& id(rawPosition.id) === id(positions[0].id) && id(rawPosition.balanceId) === id(balance.id)
|
||||
&& id(rawPosition.palletId) === member.palletId && rawPosition.palletNo === member.palletNo
|
||||
@@ -131,7 +137,9 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
for (const suffix of ['A', 'B']) {
|
||||
const memberDestinationId = id(memberDestinations[suffix === 'A' ? 0 : 1])
|
||||
const { destination, deliveryAddress } = destinations.get(memberDestinationId)
|
||||
const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false }
|
||||
const configuredCustomer = normalizeFixedCustomerId(suffix === 'A' ? context.options.fixedCustomerId : context.options.fixedSecondaryCustomer.customerId)
|
||||
const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false,
|
||||
...(configuredCustomer ? { customerId: configuredCustomer } : {}) }
|
||||
proof.members.push(member)
|
||||
// Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot.
|
||||
const child = Object.create(context)
|
||||
@@ -140,23 +148,21 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
child.context = {}; child.createdResources = {}
|
||||
child.request = async (method, path, body, token = context.token) => {
|
||||
const response = await context.request(method, path, body, token)
|
||||
record(method, path, body, response, `Qualify fresh customer ${suffix}`)
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/tenants')
|
||||
member.customerId = id((response.body.data?.tenant || response.body.data?.customer || response.body.data)?.id || response.body.data?.tenantId)
|
||||
record(method, path, body, response, `Verify customer ${suffix}`)
|
||||
return response
|
||||
}
|
||||
await createQualifiedMerchant(child, { publishEnvironment: false })
|
||||
await createQualifiedMerchant(child, { publishEnvironment: false, requireDistinctCustomer: suffix === 'B' })
|
||||
const credentials = child.context.merchantQualificationFixture
|
||||
check('Qualification belongs to this independent current-run context', credentials?.marker === member.customerMarker
|
||||
&& id(credentials.tenantId) === member.customerId && proof.members.filter(row => row.customerId === member.customerId).length === 1)
|
||||
member.qualification = child.createdResources.merchantQualificationFixture
|
||||
let auth
|
||||
try {
|
||||
auth = await request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, `Login fresh customer ${suffix}`, '')
|
||||
auth = await request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, `Login customer ${suffix}`, '')
|
||||
} finally { delete child.context.merchantQualificationFixture }
|
||||
// Retain a returned session for cleanup even if the login contract is incomplete.
|
||||
if (auth?.accessToken) sessions.push({ customerId: member.customerId, accessToken: auth.accessToken, refreshToken: auth.refreshToken })
|
||||
check('Fresh customer session is complete', auth?.accessToken && auth.refreshToken)
|
||||
check('Customer session is complete', auth?.accessToken && auth.refreshToken)
|
||||
const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken)
|
||||
check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId)
|
||||
const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId),
|
||||
|
||||
@@ -60,12 +60,8 @@ function harness(fault) {
|
||||
return response({ id: p.split('/').at(-1), status: failed ? 'FAILED' : 'SUCCEEDED', failureMessage: failed ? '筛选参数无效' : null })
|
||||
}
|
||||
if (p === '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') return response({ trip: { tripNo, warehouseId: '92', departedAt, status: 'IN_TRANSIT', remark: fault === 'historical trip' ? 'OLD' : marker } })
|
||||
if (p === '/api/platform/v1/suppliers' && method === 'POST') {
|
||||
for (const field of ['name', 'region', 'legalRepresentative', 'contactName', 'phone', 'settlementPeriod']) assert.ok(body[field], field)
|
||||
assert.equal(body.truckingScope, 'OVERSEAS'); return response('901')
|
||||
}
|
||||
if (p === '/api/platform/v1/suppliers/901/status') { supplierStatus = body.status; return response(null) }
|
||||
if (p === '/api/platform/v1/suppliers/901') return response({ id: '901', status: supplierStatus })
|
||||
if (p === '/api/platform/v1/suppliers') { assert.equal(method, 'GET'); return response({ records: [{ id: '901', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: supplierStatus }], total: 1 }) }
|
||||
if (p === '/api/platform/v1/suppliers/901') { assert.equal(method, 'GET'); return response({ id: '901', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: supplierStatus }) }
|
||||
if (p === '/api/platform/v1/last-leg/fleet/quotes/import') {
|
||||
const backup = { rows: copy(rows), histories: copy(histories), commands: copy(commands) }, result = []
|
||||
for (let index = 0; index < body.rows.length; index++) {
|
||||
@@ -134,7 +130,7 @@ test('offline full flow checks quotes, atomic import, current departure and meas
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.financePayment, 'NOT_TESTED'); assert.equal(proof.upstreamDeparture, 'NOT_TESTED')
|
||||
assert.equal(proof.operationsStatus, 'NOT_TESTED'); assert.equal(proof.quoteStatus, 'VERIFIED')
|
||||
assert.equal(context.createdResources.fleetOperationsAcceptance.operationsStatus, 'VERIFIED'); assert.equal(context.createdResources.fleetOperationsAcceptance.quoteStatus, 'NOT_TESTED')
|
||||
assert.equal(rows.size, 4); assert.ok([...rows.values()].every(r => r.status === 'INACTIVE')); assert.equal(supplierStatus(), 'INACTIVE')
|
||||
assert.equal(rows.size, 4); assert.ok([...rows.values()].every(r => r.status === 'INACTIVE')); assert.equal(supplierStatus(), 'ACTIVE')
|
||||
assert.ok(!calls.some(call => call.method === 'DELETE')); assert.ok(!JSON.stringify(proof).includes('password'))
|
||||
})
|
||||
for (const fault of ['null price becomes zero', 'mutable creation replay', 'partial import leak', 'inactive leaks into active list', 'stale write accepted', 'counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'json export masquerades as csv', 'historical trip']) {
|
||||
|
||||
@@ -227,14 +227,9 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
|
||||
}
|
||||
if (scope === 'QUOTES_ONLY') {
|
||||
const customer = requireQuoteCustomer(context)
|
||||
const supplier = context.options?.fixedCustomerId
|
||||
? await (await import('./finance-full-fixtures.mjs')).loadSupplier(context, 'TRUCKING', { truckingScope: 'OVERSEAS' })
|
||||
: await command('POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker,
|
||||
region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30',
|
||||
}, '新建本轮海外车队供应商')
|
||||
const supplier = await (await import('./finance-full-fixtures.mjs')).loadSupplier(context, 'TRUCKING', { truckingScope: 'OVERSEAS' })
|
||||
if (!supplier) throw new Error('缺少现有海外拖车供应商,不自动创建')
|
||||
proof.supplierReused = Boolean(context.options?.fixedCustomerId)
|
||||
proof.supplierReused = true
|
||||
proof.supplierId = id(supplier.id ?? supplier)
|
||||
check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId))
|
||||
const day = new Date().toISOString().slice(0, 10)
|
||||
@@ -315,17 +310,6 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
|
||||
if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史')
|
||||
} catch (error) { cleanupErrors.push(error.message) }
|
||||
}
|
||||
if (proof.supplierId && !proof.supplierReused && !context.quoteRequestOutcomeUnknown) {
|
||||
try {
|
||||
const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}`
|
||||
const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' })
|
||||
context.apiSuccess(MODULE, '停用本轮车队供应商并保留证据', response, '', OPTIONS)
|
||||
check('供应商停用命令成功', response, successful(response))
|
||||
const supplier = await command('GET', `${path}?tenantId=0`, undefined, '回查本轮车队供应商已停用')
|
||||
check('本轮车队供应商最终停用', null, supplier.status === 'INACTIVE' && id(supplier.id) === proof.supplierId)
|
||||
} catch (error) { cleanupErrors.push(error.message) }
|
||||
}
|
||||
proof.cleanup = context.quoteRequestOutcomeUnknown ? 'NOT_ATTEMPTED_UNKNOWN_OUTCOME' : cleanupErrors.length ? 'FAILED' : proof.quotes.length || proof.supplierId ? 'RETAINED_AND_DEACTIVATED' : 'NOTHING_CREATED'
|
||||
if (cleanupErrors.length) { proof.cleanupErrors = cleanupErrors; proof.status = 'FAILED'; failure ||= new Error('本轮报价资源停用失败') }
|
||||
}
|
||||
if (failure) throw failure
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { qualificationCreditCode, normalizeFixedCustomerId, run } from './merchant-qualification-fixture.mjs'
|
||||
import { normalizeFixedCustomerId, run } from './merchant-qualification-fixture.mjs'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
function fixedContext(profileId = '2100000000000000001') {
|
||||
@@ -76,13 +76,26 @@ for (const fault of ['same customer', 'wrong profile', 'missing credentials']) t
|
||||
else assert.equal(context.calls.at(-1).path, '/api/auth/logout')
|
||||
})
|
||||
|
||||
test('TEST fixture without fixed customer fails before any request', async () => {
|
||||
for (const environment of ['test', 'gray']) test(`${environment} fixture without fixed customer fails before any request`, async () => {
|
||||
const context = fixedContext()
|
||||
context.options = { environment: 'test' }
|
||||
context.options = { environment }
|
||||
await assert.rejects(run(context), /必须配置固定客户/)
|
||||
assert.equal(context.calls.length, 0)
|
||||
})
|
||||
|
||||
test('GRAY fixed customer reuses its credentials and does not publish them into TEST variables', async () => {
|
||||
const context = fixedContext(); context.options.environment = 'gray'
|
||||
const before = Object.fromEntries(['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_USERNAME', 'MERCHANT_GRAY_PASSWORD'].map(key => [key, process.env[key]]))
|
||||
try {
|
||||
await run(context)
|
||||
assert.equal(context.createdResources.merchantQualificationFixture.reused, true)
|
||||
assert.equal(process.env.MERCHANT_TEST_USERNAME, before.MERCHANT_TEST_USERNAME)
|
||||
assert.equal(process.env.MERCHANT_TEST_PASSWORD, before.MERCHANT_TEST_PASSWORD)
|
||||
assert.equal(process.env.MERCHANT_GRAY_USERNAME, context.options.merchantUsername)
|
||||
assert.equal(context.calls.some(call => call.path === '/api/platform/v1/iam/tenants'), false)
|
||||
} finally { for (const [key, value] of Object.entries(before)) { if (value === undefined) delete process.env[key]; else process.env[key] = value } }
|
||||
})
|
||||
|
||||
test('cross-customer financial helpers stop before provisioning or writing', async () => {
|
||||
for (const [file, name, end] of [
|
||||
['finance-direct-paid-isolation.mjs', 'createSecondCustomer', 'async function verifyCustomerIsolation'],
|
||||
@@ -133,29 +146,15 @@ const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', impo
|
||||
const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8')
|
||||
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('credit codes distinguish repeated CUSTOMER-B and SIGNED-B fixtures using the complete marker', () => {
|
||||
const customerId = '2100000000000000001'
|
||||
const values = []
|
||||
for (const ending of ['CUSTOMER-B', 'SIGNED-B']) {
|
||||
const firstMarker = `ADMIN-API-20260930121500000-${ending}`
|
||||
const secondMarker = `ADMIN-API-20260930121600000-${ending}`
|
||||
const first = qualificationCreditCode(firstMarker, customerId)
|
||||
const second = qualificationCreditCode(secondMarker, customerId)
|
||||
assert.notEqual(first, second, `时间不同但后缀同为 ${ending} 必须生成不同代码`)
|
||||
assert.equal(qualificationCreditCode(firstMarker, customerId), first)
|
||||
for (const value of [first, second]) assert.match(value, /^91440300[0-9A-F]{10}$/)
|
||||
values.push(first, second)
|
||||
for (const environment of [undefined, 'dev', 'prod']) test(`unconfigured customer fixture never provisions even outside normal entry: ${environment}`, async () => {
|
||||
const context = fixedContext(); context.options = { environment }
|
||||
await assert.rejects(run(context), /必须配置固定客户/)
|
||||
assert.equal(context.calls.length, 0)
|
||||
const runner = new AcceptanceRun({ environment })
|
||||
for (const entry of ['request', 'requestWithBody', 'requestMultipart']) {
|
||||
for (const path of ['/api/platform/v1/iam/tenants/', '/api/auth/register', '/api/platform/v1/iam/tenants/123/archive'])
|
||||
await assert.rejects(runner[entry]('POST', path, new FormData()), /固定客户模式禁止/)
|
||||
}
|
||||
assert.equal(new Set(values).size, values.length)
|
||||
assert.match(fixture, /unifiedSocialCreditCode = qualificationCreditCode\(context\.marker, tenantId\)/)
|
||||
})
|
||||
|
||||
test('a new real customer under the same marker receives a distinct stable code', () => {
|
||||
const marker = 'ADMIN-API-20260930121500000-CUSTOMER-B'
|
||||
assert.notEqual(qualificationCreditCode(marker, '2100000000000000001'),
|
||||
qualificationCreditCode(marker, '2100000000000000002'))
|
||||
assert.throws(() => qualificationCreditCode('', '2100000000000000001'), /完整批次标识/)
|
||||
assert.throws(() => qualificationCreditCode(marker, ''), /真实客户编号/)
|
||||
})
|
||||
|
||||
test('默认全量验收先准备已审核商户再运行订单链路', () => {
|
||||
@@ -165,24 +164,12 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => {
|
||||
assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/)
|
||||
})
|
||||
|
||||
test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => {
|
||||
assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/)
|
||||
assert.match(fixture, /required-package\?unifiedSocialCreditCode=\$\{encodeURIComponent\(unifiedSocialCreditCode\)\}/)
|
||||
assert.doesNotMatch(fixture, /packageForm\.append\('unifiedSocialCreditCode'/)
|
||||
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/)
|
||||
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/)
|
||||
test('商户夹具只核验现有客户,不包含建客、上传资质和审核实现', () => {
|
||||
assert.match(fixture, /dimension-initialization/)
|
||||
assert.match(fixture, /status === 'READY'/)
|
||||
assert.match(fixture, /setTimeout\(resolve, 500\)/)
|
||||
assert.match(fixture, /iam\/reviews\/\$\{encodeURIComponent\(review\.id\)\}\/approve/)
|
||||
assert.match(fixture, /item\.status === 'APPROVED'/)
|
||||
assert.match(fixture, /MERCHANT_TEST_USERNAME/)
|
||||
assert.match(fixture, /MERCHANT_TEST_PASSWORD/)
|
||||
assert.match(fixture, /context\.options\?\.customerName/)
|
||||
assert.match(fixture, /tenantName/)
|
||||
assert.doesNotMatch(fixture, /expectedProcurementPrefix/)
|
||||
assert.match(acceptance, /administrator-phone-availability\?phone=/)
|
||||
assert.match(acceptance, /attempt < 100/)
|
||||
assert.doesNotMatch(acceptance, /phoneSequence % 10/)
|
||||
assert.match(fixture, /environment === 'gray' \? 'MERCHANT_GRAY' : 'MERCHANT_TEST'/)
|
||||
for (const removed of ['required-package', 'submit-review', '/approve', 'administratorUsername', 'temporaryPassword'])
|
||||
assert.ok(!fixture.includes(removed), removed)
|
||||
assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'merchant-qualification-fixture'
|
||||
const REQUIRED_TYPES = new Set(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'])
|
||||
const QUALIFICATION_IMAGE = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
|
||||
function id(value) {
|
||||
return value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
export function qualificationCreditCode(marker, tenantId) {
|
||||
const customerId = id(tenantId)
|
||||
if (!String(marker || '').trim() || !customerId) {
|
||||
throw new Error('测试资质代码必须使用完整批次标识和本轮真实客户编号')
|
||||
}
|
||||
const suffix = createHash('sha256').update(JSON.stringify([String(marker), customerId]))
|
||||
.digest('hex').slice(0, 10).toUpperCase()
|
||||
return `91440300${suffix}`
|
||||
}
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
async function waitForDimensionInitialization(context, tenantId) {
|
||||
let response = null
|
||||
@@ -56,9 +36,10 @@ export function requireFixedSecondaryCustomer(options) {
|
||||
async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) {
|
||||
const secondary = requireDistinctCustomer ? requireFixedSecondaryCustomer(context.options) : null
|
||||
const tenantId = normalizeFixedCustomerId(secondary?.customerId || context.options.fixedCustomerId)
|
||||
const username = secondary ? secondary.username : context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME
|
||||
const password = secondary ? secondary.password : context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
|
||||
if (!username || !password) throw new Error('请配置固定客户的 MERCHANT_TEST_USERNAME / MERCHANT_TEST_PASSWORD;不会新建客户或重置密码')
|
||||
const prefix = context.options.environment === 'gray' ? 'MERCHANT_GRAY' : 'MERCHANT_TEST'
|
||||
const username = secondary ? secondary.username : context.options.merchantUsername || process.env[`${prefix}_USERNAME`]
|
||||
const password = secondary ? secondary.password : context.options.merchantPassword || process.env[`${prefix}_PASSWORD`]
|
||||
if (!username || !password) throw new Error(`请配置固定客户的 ${prefix}_USERNAME / ${prefix}_PASSWORD;不会新建客户或重置密码`)
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', {loginId:username,password}, '')
|
||||
const login = context.apiSuccess(MODULE, '登录已配置的固定客户账号', loginResponse)
|
||||
if (!login?.accessToken) throw new Error('固定客户账号登录失败')
|
||||
@@ -75,8 +56,8 @@ async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCu
|
||||
await context.request('POST', '/api/auth/logout', {refreshToken:login.refreshToken}, login.accessToken)
|
||||
}
|
||||
if (publishEnvironment) {
|
||||
process.env.MERCHANT_TEST_USERNAME = username
|
||||
process.env.MERCHANT_TEST_PASSWORD = password
|
||||
process.env[`${prefix}_USERNAME`] = username
|
||||
process.env[`${prefix}_PASSWORD`] = password
|
||||
}
|
||||
const fixture = {tenantId,tenantName:profile.tenantName,username,marker:context.marker,reused:true}
|
||||
context.context.merchantQualificationFixture = {...fixture,password}
|
||||
@@ -85,91 +66,6 @@ async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCu
|
||||
}
|
||||
|
||||
export async function run(context, { publishEnvironment = true, requireDistinctCustomer = !publishEnvironment } = {}) {
|
||||
if (context.options?.fixedCustomerId) return reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer)
|
||||
if (context.options?.environment === 'test') throw new Error('TEST 必须配置固定客户,禁止自动建客')
|
||||
const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0')
|
||||
const customerName = String(context.options?.customerName || '').trim()
|
||||
const tenantName = customerName || `全量验收下单客户-${suffix}`
|
||||
const phone = await context.findAvailablePhone(MODULE)
|
||||
const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', {
|
||||
tenantName,
|
||||
legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`,
|
||||
legalRepresentative: '全量验收法人',
|
||||
contactName: '全量验收管理员',
|
||||
contactPhone: phone,
|
||||
contactEmail: `merchant-fixture-${suffix.toLowerCase()}@example.com`,
|
||||
servicePackage: '全量验收',
|
||||
salesOwnerName: '全量验收',
|
||||
paymentTermDays: 30,
|
||||
enterpriseType: 'LIMITED_COMPANY',
|
||||
establishedDate: '2020-01-02',
|
||||
registeredCapital: 1000000,
|
||||
registeredCapitalCurrency: 'CNY',
|
||||
registeredAddress: '深圳市全量验收地址',
|
||||
internalRemark: `${context.marker}-MERCHANT-QUALIFICATION`,
|
||||
})
|
||||
const created = context.apiSuccess(MODULE, '创建隔离下单客户', createResponse)
|
||||
const customer = created?.tenant || created?.customer || created
|
||||
const tenantId = id(customer?.id || created?.tenantId)
|
||||
const username = created?.administratorUsername
|
||||
const password = created?.temporaryPassword
|
||||
context.assert(MODULE, '下单客户和一次性管理员凭据完整', createResponse,
|
||||
Boolean(tenantId && username && password),
|
||||
`tenantId=${tenantId || 'missing'}, username=${username || 'missing'}, passwordGenerated=${Boolean(password)}`,
|
||||
{ deploymentBlocker: true })
|
||||
if (!tenantId || !username || !password) throw new Error('创建下单客户未返回完整管理员凭据')
|
||||
|
||||
const packageForm = new FormData()
|
||||
packageForm.append('businessLicense', new File([QUALIFICATION_IMAGE], `business-${suffix}.png`, { type: 'image/png' }))
|
||||
packageForm.append('legalPersonId', new File([QUALIFICATION_IMAGE], `legal-person-${suffix}.png`, { type: 'image/png' }))
|
||||
const unifiedSocialCreditCode = qualificationCreditCode(context.marker, tenantId)
|
||||
const packageResponse = await context.requestWithBody('POST',
|
||||
`/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`,
|
||||
packageForm)
|
||||
context.apiSuccess(MODULE, '通过 File 上传真实必填资质包', packageResponse,
|
||||
`tenantId=${tenantId}`, { deploymentBlocker: true })
|
||||
|
||||
const submitResponse = await context.request('POST',
|
||||
`/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/submit-review`)
|
||||
const submitted = context.apiSuccess(MODULE, '提交企业资质审核', submitResponse,
|
||||
`tenantId=${tenantId}`, { deploymentBlocker: true })
|
||||
context.assert(MODULE, '企业进入待审核状态', submitResponse, submitted?.status === 'PENDING',
|
||||
`status=${submitted?.status ?? 'missing'}`, { deploymentBlocker: true })
|
||||
|
||||
const reviewsResponse = await context.request('GET', '/api/platform/v1/iam/reviews')
|
||||
const reviews = context.apiSuccess(MODULE, '查询待审核企业', reviewsResponse, '', { deploymentBlocker: true })
|
||||
const review = Array.isArray(reviews) ? reviews.find((item) => id(item.tenantId) === tenantId) : null
|
||||
context.assert(MODULE, '待审核列表包含隔离下单客户', reviewsResponse, Boolean(review?.id),
|
||||
`tenantId=${tenantId}, reviewId=${id(review?.id) || 'missing'}`, { deploymentBlocker: true })
|
||||
if (!review?.id) throw new Error('未找到隔离下单客户的待审核单')
|
||||
|
||||
await waitForDimensionInitialization(context, tenantId)
|
||||
|
||||
const approveResponse = await context.request('POST',
|
||||
`/api/platform/v1/iam/reviews/${encodeURIComponent(review.id)}/approve`,
|
||||
{ comment: `${context.marker} 全量验收正式资质审核通过` })
|
||||
context.apiSuccess(MODULE, '平台审核通过企业资质', approveResponse,
|
||||
`tenantId=${tenantId}`, { deploymentBlocker: true })
|
||||
|
||||
const qualificationsResponse = await context.request('GET',
|
||||
`/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/qualifications`)
|
||||
const qualifications = context.apiSuccess(MODULE, '回查企业资质审核结果', qualificationsResponse)
|
||||
const approvedTypes = new Set((Array.isArray(qualifications) ? qualifications : [])
|
||||
.filter((item) => item.status === 'APPROVED')
|
||||
.map((item) => item.qualificationType))
|
||||
const eligible = [...REQUIRED_TYPES].every((type) => approvedTypes.has(type))
|
||||
context.assert(MODULE, '全部必填企业资质已审核通过', qualificationsResponse, eligible,
|
||||
`approved=${[...approvedTypes].join(',') || 'missing'}`, { deploymentBlocker: true })
|
||||
if (!eligible) throw new Error('隔离下单客户必填资质未全部审核通过')
|
||||
|
||||
if (publishEnvironment) {
|
||||
process.env.MERCHANT_TEST_USERNAME = username
|
||||
process.env.MERCHANT_TEST_PASSWORD = password
|
||||
}
|
||||
context.context.merchantQualificationFixture = {
|
||||
tenantId, tenantName, username, password, marker: context.marker,
|
||||
}
|
||||
context.createdResources.merchantQualificationFixture = {
|
||||
tenantId, tenantName, username, marker: context.marker,
|
||||
}
|
||||
if (!context.options?.fixedCustomerId) throw new Error('必须配置固定客户,禁止自动建客、补交资质或自动审核')
|
||||
return reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { pickerFixtureSuffix, createPicker, disablePicker, pickerSessionHandoff } from './phase4-overseas-pda.mjs'
|
||||
import { pickerFixtureSuffix, createPicker, disablePicker, pickerSessionHandoff, restoreFixedPicker } from './phase4-overseas-pda.mjs'
|
||||
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
test('child workflows share PDA leases and recovery reuses the saved membership without enabling or recreating an account', async () => {
|
||||
const actors = ['11', '12'].map(membershipId => ({ membershipId, warehouseId: '91', username: `test-${membershipId}`, password: 'fixture-only' }))
|
||||
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: '101', fixedPdaActors: actors }), calls = []
|
||||
context.request = async (method, path, body) => {
|
||||
calls.push({ method, path })
|
||||
let data = {}
|
||||
if (path.includes('/iam/users?')) {
|
||||
assert.equal(new URL(path, 'https://fixture.invalid').searchParams.has('keyword'), false)
|
||||
data = { page: 1, size: 100, total: 2, records: actors.map(actor => ({ ...actor, overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] })) }
|
||||
}
|
||||
if (path === '/api/auth/login') data = { accessToken: body.loginId, refreshToken: `refresh-${body.loginId}` }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
}
|
||||
const full = await createPicker(Object.create(context), 161, '91', { role: { id: '7' } })
|
||||
const partial = await createPicker(Object.create(context), 71, '91', { role: { id: '7' } })
|
||||
assert.notEqual(full.membershipId, partial.membershipId)
|
||||
await disablePicker(context, full, 161)
|
||||
await disablePicker(context, partial, 71)
|
||||
const restored = await restoreFixedPicker(context, { membershipId: '12', username: 'test-12' }, 71, '91', { role: { id: '7' } })
|
||||
assert.equal(restored.membershipId, '12')
|
||||
await disablePicker(context, restored, 71)
|
||||
assert.equal(calls.filter(call => call.path === '/api/auth/logout').length, 3)
|
||||
assert.equal(context.fixedPdaLeases.size, 0)
|
||||
assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path)))
|
||||
const before = calls.length
|
||||
await assert.rejects(restoreFixedPicker(context, { membershipId: '999', username: 'foreign' }, 71, '91', { role: { id: '7' } }), /禁止推算临时账号/)
|
||||
assert.equal(calls.length, before)
|
||||
})
|
||||
|
||||
test('fixed PDA actors are leased separately and only logged out, never modified', async () => {
|
||||
const actors = ['11', '12'].map(membershipId => ({ membershipId, warehouseId: '91', username: `test-${membershipId}`, password: 'fixture-only' }))
|
||||
|
||||
@@ -367,30 +367,17 @@ export async function run(context) {
|
||||
approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4,
|
||||
`status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
let customsAgentId
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
|
||||
const existing = await loadSupplier(context, 'CUSTOMS')
|
||||
if (!existing) throw new Error('缺少现有报关代理,不自动创建供应商')
|
||||
customsAgentId = id(existing.id)
|
||||
} else {
|
||||
const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳',
|
||||
legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000',
|
||||
email: 'customs-acceptance@example.com', address: '深圳市验收地址',
|
||||
businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null,
|
||||
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
|
||||
})
|
||||
customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商',
|
||||
supplierCreateResponse, `marker=${context.marker}`, OPTIONS))
|
||||
}
|
||||
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
|
||||
const existing = await loadSupplier(context, 'CUSTOMS')
|
||||
if (!existing) throw new Error('缺少现有报关代理,不自动创建供应商')
|
||||
const customsAgentId = id(existing.id)
|
||||
if (!customsAgentId) return
|
||||
const supplierDetailResponse = await context.request('GET',
|
||||
`/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`)
|
||||
const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse,
|
||||
id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE'
|
||||
&& customsAgent?.category === 'CUSTOMS' && (context.options?.fixedCustomerId || customsAgent?.remark === context.marker),
|
||||
&& customsAgent?.category === 'CUSTOMS',
|
||||
`customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS)
|
||||
if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE'
|
||||
|| customsAgent?.category !== 'CUSTOMS') return
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'phase3-loading'
|
||||
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
|
||||
const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'
|
||||
const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
@@ -41,31 +40,11 @@ function failure(response) {
|
||||
}
|
||||
function containerRecord(value) { return value?.container }
|
||||
|
||||
async function ensureMatsonSupplier(context, records) {
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
|
||||
const supplier = await loadSupplier(context, 'BOOKING', { carrierCode: 'MATSON' })
|
||||
if (!supplier) throw new Error('缺少启用的 MATSON 订舱供应商,不创建或启用主档')
|
||||
return supplier
|
||||
}
|
||||
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
|
||||
if (existing?.status === 'ACTIVE') return existing
|
||||
if (existing?.id) {
|
||||
const activatedResponse = await context.request('PATCH',
|
||||
`/api/platform/v1/suppliers/${encodeURIComponent(id(existing.id))}/status?tenantId=0`, { status: 'ACTIVE' })
|
||||
const activated = context.apiSuccess(MODULE, '启用稳定MATSON船司开发夹具', activatedResponse,
|
||||
`supplierId=${id(existing.id)}`, OPTIONS)
|
||||
return activated?.id ? activated : null
|
||||
}
|
||||
const createdResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
name: MATSON_SUPPLIER_NAME, category: 'BOOKING', carrierCodes: ['MATSON'], region: '开发环境', legalRepresentative: '开发联调',
|
||||
contactName: '开发联调', phone: '13800000000', email: 'matson-dev-fixture@example.com',
|
||||
address: '开发环境稳定船司夹具', businessLicenseNo: 'DEV-CARRIER-MATSON', licenseFileId: null,
|
||||
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: '三四期开发环境HTTP验收稳定船司夹具',
|
||||
})
|
||||
const supplierId = id(context.apiSuccess(MODULE, '创建稳定MATSON船司开发夹具', createdResponse,
|
||||
`name=${MATSON_SUPPLIER_NAME}`, OPTIONS))
|
||||
return supplierId ? { id: supplierId, name: MATSON_SUPPLIER_NAME, status: 'ACTIVE' } : null
|
||||
async function ensureMatsonSupplier(context) {
|
||||
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
|
||||
const supplier = await loadSupplier(context, 'BOOKING', { carrierCode: 'MATSON' })
|
||||
if (!supplier) throw new Error('缺少启用的 MATSON 订舱供应商,不创建或启用主档')
|
||||
return supplier
|
||||
}
|
||||
|
||||
export function stableLoadedCartonMarks(items) {
|
||||
@@ -228,7 +207,7 @@ export async function run(context) {
|
||||
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200')
|
||||
const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS)
|
||||
const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : []
|
||||
const supplier = await ensureMatsonSupplier(context, supplierRecords)
|
||||
const supplier = await ensureMatsonSupplier(context)
|
||||
context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse,
|
||||
Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS)
|
||||
if (!supplier?.id) return
|
||||
|
||||
@@ -468,9 +468,9 @@ test('preload and loading versions use immediate baselines and stable automatic
|
||||
assert.match(loading, /自动关联保持 EDITING、身份稳定且无需额外推送版本/)
|
||||
assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/)
|
||||
assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
|
||||
assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/)
|
||||
assert.match(loading, /loadSupplier\(context, 'BOOKING', \{ carrierCode: 'MATSON' \}\)/)
|
||||
assert.match(loading, /category=BOOKING&page=1&size=200/)
|
||||
assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/)
|
||||
assert.match(loading, /ensureMatsonSupplier\(context\)/)
|
||||
assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/)
|
||||
assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/)
|
||||
assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/)
|
||||
@@ -649,8 +649,8 @@ test('customs reuses the loading chain released booking without pre-completing t
|
||||
assert.doesNotMatch(value, /oceanBookings\.run\(context\)/)
|
||||
assert.doesNotMatch(value, /serviceCenter\.run\(context\)/)
|
||||
assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/)
|
||||
assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
|
||||
assert.match(value, /category: 'CUSTOMS'/)
|
||||
assert.match(value, /loadSupplier\(context, 'CUSTOMS'\)/)
|
||||
assert.doesNotMatch(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
|
||||
assert.match(value, /context\.createdResources\.phase3CustomsSupplier/)
|
||||
assert.match(value, /const batchPayload = \{ customsAgentId,/)
|
||||
assert.doesNotMatch(value, /customsAgentName: `代理-/)
|
||||
|
||||
@@ -330,7 +330,7 @@ async function reusePicker(context, index, overseasWarehouseId, access) {
|
||||
try {
|
||||
let user
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`)
|
||||
const result = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100`)
|
||||
const data = context.apiSuccess(MODULE, '核对固定PDA账号', result, '', OPTIONS)
|
||||
user = data?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username)
|
||||
if (user || !data?.records?.length || page * Number(data.size || 100) >= Number(data.total || 0)) break
|
||||
@@ -404,6 +404,17 @@ export function pickerSessionHandoff(picker) {
|
||||
userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined }
|
||||
}
|
||||
|
||||
export async function restoreFixedPicker(context, savedPicker, index, warehouseId, access) {
|
||||
const actor = context.options?.fixedCustomerId && context.options.fixedPdaActors?.find(row =>
|
||||
id(row.membershipId) === id(savedPicker?.membershipId) && row.username === savedPicker?.username
|
||||
&& id(row.warehouseId) === id(warehouseId))
|
||||
if (!actor) throw Error('原PDA身份不在当前固定账号池或仓绑定不匹配;禁止推算临时账号、启用账号或重建')
|
||||
context.fixedPdaLeases ??= new Set()
|
||||
const child = Object.create(context)
|
||||
child.options = { ...context.options, fixedPdaActors: [actor] }
|
||||
return createPicker(child, index, warehouseId, access)
|
||||
}
|
||||
|
||||
export async function disablePicker(context, picker, index) {
|
||||
if (!longId(picker?.membershipId)) return
|
||||
if (picker.reused) {
|
||||
|
||||
@@ -70,25 +70,23 @@ test('第二客户准备运行真实fixture编排但不替换主客户内存与
|
||||
process.env.MERCHANT_TEST_PASSWORD = 'primary-placeholder'
|
||||
const primary = { context: { merchantQualificationFixture: { tenantId: 'A' } }, createdResources: {} }
|
||||
const secondary = Object.create(primary)
|
||||
Object.assign(secondary, { marker: 'OFFLINE-20260915-B', options: {}, context: { ...primary.context }, createdResources: {},
|
||||
Object.assign(secondary, { marker: 'OFFLINE-20260915-B', options: { fixedCustomerId: '101', merchantUsername: 'secondary-user', merchantPassword: 'secondary-placeholder' }, context: { ...primary.context }, createdResources: {},
|
||||
findAvailablePhone: async () => '00000000000',
|
||||
assert: (module, name, result, condition) => assert.ok(condition, name),
|
||||
apiSuccess: (module, name, result) => { assert.equal(result.body.code, '200', name); return result.body.data },
|
||||
requestWithBody: async () => ({ body: { code: '200', data: {} } }),
|
||||
request: async (method, path) => {
|
||||
let data
|
||||
if (path.endsWith('/iam/tenants')) data = { id: 'B', administratorUsername: 'secondary-user', temporaryPassword: 'secondary-placeholder' }
|
||||
else if (path.endsWith('/submit-review')) data = { status: 'PENDING' }
|
||||
else if (path.endsWith('/iam/reviews')) data = [{ id: 'review-B', tenantId: 'B' }]
|
||||
if (path.endsWith('/login')) data = { accessToken: 'secondary-token', refreshToken: 'secondary-refresh' }
|
||||
else if (path.endsWith('/profile')) data = { id: '101', status: 'ACTIVE', tenantName: 'Existing customer' }
|
||||
else if (path.endsWith('/dimension-initialization')) data = { status: 'READY' }
|
||||
else if (path.endsWith('/approve')) data = {}
|
||||
else if (path.endsWith('/qualifications')) data = ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' }))
|
||||
else if (path.endsWith('/logout')) data = null
|
||||
else throw new Error(`Unexpected fixture request: ${method} ${path}`)
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} })
|
||||
await createMerchant(secondary, { publishEnvironment: false })
|
||||
await createMerchant(secondary, { publishEnvironment: false, requireDistinctCustomer: false })
|
||||
assert.equal(primary.context.merchantQualificationFixture.tenantId, 'A')
|
||||
assert.equal(secondary.context.merchantQualificationFixture.tenantId, 'B')
|
||||
assert.equal(secondary.context.merchantQualificationFixture.tenantId, '101')
|
||||
assert.equal(process.env.MERCHANT_TEST_USERNAME, 'primary-user')
|
||||
assert.equal(process.env.MERCHANT_TEST_PASSWORD, 'primary-placeholder')
|
||||
assert.ok(!JSON.stringify(secondary.createdResources).includes('secondary-placeholder'))
|
||||
|
||||
Reference in New Issue
Block a user