feat(procurement): implement sample fulfillment and settled fee workflow

This commit is contained in:
2026-10-10 02:19:38 +08:00
parent 040f3bbd93
commit a973be2103
104 changed files with 9823 additions and 714 deletions
+3
View File
@@ -1,5 +1,8 @@
# 后端脚本管理与全量 HTTP 验收索引
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
`db/local-four-flows-schema-incremental-20261009.sql`:本地退货换标、仓储转运、贴标出库及退货出库结构比对所得的 MySQL 8 定向增量。显式 `USE baiyue_lastleg`,补齐预约结束时间/版本、转运换标来源绑定、分配 SKU 快照及转运收货幂等命令表;字段按存在性判断,表使用 `CREATE TABLE IF NOT EXISTS`,可重复执行,不删除或回填业务数据。使用操作者显式选择的本地数据库连接,无内置凭据;完整执行输出 `LOCAL_FLOW_SCHEMA_READY`。失败保留已完成的 DDL 与原错误,核对后重跑;不自动清理业务记录,不是全库迁移或生产验收入口。
`db/return-tail-order-number-incremental-20261009.sql` 将已验证关联的退货尾程单号及退货单尾程号快照统一为退货单号。显式 `USE baiyue_lastleg`,可重复执行,只更新同客户、关联订单 ID 和来源退货号均一致的 `RETURN_RELABEL` 单据;不改变业务 ID、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。
+6 -2
View File
@@ -1,5 +1,7 @@
# Admin 测试环境 HTTP 模块
`procurement-sample-fulfillment.mjs` 是采购样品完整正常路径的内部验收助手,钱包综合模块也调用同一流程,并将采购请求证据独占保存于 `reports/procurement-wallet-checkpoints/<marker>.json`;客户会话在 finally 退出。由 `../procurement-sample-test-env.mjs` 注入本轮独立已充值客户、客户会话、有效收货仓、受控上传及权威钱包读取。创建新样本,执行正额货款、两次海外收货、两箱装箱、预估冻结、国际交寄、逐箱国内收货、实际核费及寄客户,逐阶段核对钱包现金/冻结和费用来源,关键请求原键重放。每次请求前必须 checkpoint;未知结果停写保留样本,不自动新键重试或清理。已接入显式 TEST 入口;未执行时不计作真实 HTTP 通过,也不覆盖异常、退款或权限全部矩阵。离线合同 `procurement-sample-fulfillment-contract.test.mjs` 验证前置缺失、余额不足、请求前持久化及未知结果停写。
`phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。
`fleet-capacity-pool-schema-contract.test.mjs` 是离线数据库合同:验证发布与持久命令唯一约束、唯一商业履约来源、可空实际数量、服务与统一基线一致、非破坏性结构补齐及独立销售/调度权限;不访问环境、不证明真实履约已通过。
@@ -269,7 +271,7 @@ export async function run(context) {
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购两箱完整履约、货款实扣/预估冻结/实际核费、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs`、`pda-picker-fixture-contract.test.mjs`(长场景名保留完整运行标识,避免造数账号跨轮重复) |
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
@@ -279,7 +281,7 @@ export async function run(context) {
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;依赖本轮海外签收,保留有有效余量根订单的 `DELIVERED`;完成订单样本经 `phase5-root-fulfillment.mjs` 两票真实签收和只读完成 Outbox 核验产生,不调用已移除的人工 `/complete`,并继续验证装柜完成、报关和订舱状态组合 |
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
@@ -431,3 +433,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。
采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。
@@ -29,11 +29,10 @@ function fixture(options = {}) {
const credentials = options.credentials === false ? {} : { username: 'merchant', password: 'secret' }
function purchaseDetail() {
return { summary: state.purchase, productUrl: state.purchase?.productUrl ?? null,
domesticCarrier: state.purchase?.domesticCarrier ?? null, domesticTrackingNo: state.purchase?.domesticTrackingNo ?? null,
estimatedArrivalAt: state.purchase?.estimatedArrivalAt ?? null, remark: state.purchase?.remark ?? null,
histories: state.purchaseHistories }
return { summary: state.purchase, remark: state.purchase?.remark ?? null, histories: state.purchaseHistories }
}
function purchaseCustomer() { return { order: state.purchase, fees: [], progress: [], boxes: [], shipments: [], exceptionSummaries: [] } }
function purchasePlatform() { return { customerView: purchaseCustomer(), internalRemark: state.purchase?.remark ?? null, reviewHistory: state.purchaseHistories } }
function truckDetail() { return { summary: state.truck, remark: state.truck?.remark ?? null, histories: state.truckHistories } }
function looseboardDetail() { return { summary: state.looseboard, remark: state.looseboard?.remark ?? null, histories: state.looseboardHistories } }
@@ -49,15 +48,15 @@ function fixture(options = {}) {
if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') {
assert.equal(token, 'merchant-overseas-token')
const hash = JSON.stringify(body)
if (state.purchase) return hash === state.purchaseHash ? response(purchaseDetail()) : response(null, 'A0409')
if (state.purchase) return hash === state.purchaseHash ? response(purchaseCustomer()) : response(null, 'A0409')
state.purchaseHash = hash
state.purchase = { id: '30001', orderNo: 'OP1', customerId: '10001', customerName: body.customerName,
purchaseWarehouseId: null, purchaseWarehouseName: null, purchaseCategory: body.purchaseCategory,
productName: body.productName, productSpecification: body.productSpecification, productUrl: body.productUrl,
purchaseQuantity: body.purchaseQuantity, inboundQuantity: 0, inventoryQuantity: 0,
purchaseQuantity: body.purchaseQuantity,
status: 'PENDING_REVIEW', version: 0, remark: body.remark }
state.purchaseHistories.push({ operationType: 'CREATE', fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker })
return response(purchaseDetail())
return response(purchaseCustomer())
}
if (requestPath.includes('/customer-options')) return response(state.purchase
? [{ customerId: '10001', customerName: '客户甲' }] : [])
@@ -71,24 +70,19 @@ function fixture(options = {}) {
if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409')
if (state.purchase.status !== 'PENDING_REVIEW') return response(null, 'A0409')
if (Number(body.version) !== state.purchase.version) return response(null, 'A0409')
const next = body.decision === 'REJECT' ? 'REJECTED' : 'APPROVED'
const next = body.decision === 'REJECT' ? 'REJECTED' : 'WAIT_PURCHASE'
state.purchase = { ...state.purchase, status: next, version: state.purchase.version + 1 }
state.purchaseHistories.push({ operationType: 'REVIEW', fromStatus: 'PENDING_REVIEW', toStatus: next, remark: body.remark })
const result = structuredClone(purchaseDetail())
state.purchaseReviews.set(body.idempotencyKey, { hash, result })
return response(result)
}
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response(purchaseDetail())
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response(purchasePlatform())
if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') {
if (Number(body.version) !== state.purchase.version) return response(null, 'A0409')
const patch = { ...body }; delete patch.version
for (const key of ['productSpecification', 'productUrl', 'domesticCarrier', 'domesticTrackingNo', 'remark']) {
if (Object.hasOwn(patch, key)) patch[key] = blankToNull(patch[key])
}
if (Object.hasOwn(patch, 'purchaseWarehouseId') && blankToNull(patch.purchaseWarehouseId) == null) {
patch.purchaseWarehouseId = null; patch.purchaseWarehouseName = null
}
state.purchase = { ...state.purchase, ...patch, version: state.purchase.version + 1 }
if (Object.keys(body).some(key => !['version', 'remark'].includes(key))) return response(null, 'A0400')
if (!Object.hasOwn(body, 'remark') || blankToNull(body.remark) === state.purchase.remark) return response(purchaseDetail())
state.purchase = { ...state.purchase, remark: blankToNull(body.remark), version: state.purchase.version + 1 }
state.purchaseHistories.push({ operationType: 'UPDATE', fromStatus: state.purchase.status,
toStatus: state.purchase.status, changeDetail: JSON.stringify({ marker }) })
return response(purchaseDetail())
@@ -180,7 +174,8 @@ function fixture(options = {}) {
} : async function(method,requestPath,body) {
calls.push({ channel: 'domestic', method, path: requestPath, body })
if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers))
if (requestPath === '/api/auth/login') return response({ accessToken: 'merchant-overseas-token' })
if (requestPath === '/api/auth/login') return response({ accessToken: 'merchant-overseas-token', refreshToken: 'merchant-refresh-token' })
if (requestPath === '/api/auth/logout') { assert.equal(body.refreshToken, 'merchant-refresh-token'); return response(null) }
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
state.warehouseStatus = 'INACTIVE'; return response(null)
@@ -222,19 +217,21 @@ test('module is context injected, blocks missing prerequisites and excludes forb
assert.doesNotMatch(source, /request\('DELETE'/)
})
test('complete procurement truck and looseboard lifecycles pass with legal marker terminals', async () => {
test('procurement creation review boundaries and transport cancellation paths pass with legal marker terminals', async () => {
const context = fixture()
await run(context)
assert.equal(context.steps.every((step) => step.passed), true,
context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
assert.equal(context.gaps.length, 0)
assert.equal(context.blockers.length, 0)
assert.equal(context.calls.filter(call => call.path === '/api/auth/login').length, 1)
assert.equal(context.calls.filter(call => call.path === '/api/auth/logout').length, 1)
const procurementWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET')
assert.deepEqual(procurementWrites.map((call) => call.method),
['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST'])
assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 4)
assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.productSpecification === null && call.body.productUrl === ''))
['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST'])
assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 5)
assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.remark === null))
const procurementReviews = procurementWrites.filter((call) => call.path.includes('/review?'))
assert.equal(procurementReviews.length, 4)
assert.ok(procurementReviews.every((call) => Number.isInteger(Number(call.body.version))
@@ -326,7 +323,7 @@ test('candidate blocker still rejects partial procurement and retires partner wa
test('contract locks candidates three-state idempotency histories legal terminals and trace evidence', () => {
for (const token of ['/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE',
'/overseas-procurements/customer-options?limit=200', '/overseas-procurements/warehouse-options',
'/api/merchant/v1/last-leg/overseas-procurements', '显式 null 与空串清空', '字段缺失不修改',
'/api/merchant/v1/last-leg/overseas-procurements', '采购备注显式清空', '缺失备注不修改且版本不漂移',
'同幂等键同载荷精确重放', '同幂等键异载荷返回冲突', '旧 version 返回 A0409',
'状态正向推进', '状态逆向回退返回冲突', '流水覆盖', "status: 'CANCELLED'",
'/review', 'truck-review-approve', 'looseboard-status-dispatched',
@@ -9,7 +9,6 @@ function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${c
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function query(path, params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) if (value !== '' && value != null) search.set(key, String(value)); return `${path}?${search}` }
function summary(data) { return data?.summary && typeof data.summary === 'object' ? data.summary : data }
function detailValue(data, field) { return data && Object.hasOwn(data, field) ? data[field] : summary(data)?.[field] }
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response).endsWith('409')) }
function versionConflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' }
function longId(value) { return /^[1-9]\d*$/.test(id(value)) }
@@ -60,7 +59,7 @@ function partnerWarehousePayload(context) {
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null,
latitude: null, longitude: null },
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true,
capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: 'Receiving', enabled: true,
cutoffTime: null, description: `${context.marker} procurement acceptance` }],
}
}
@@ -82,13 +81,13 @@ function procurementPayload(context, activeCustomer) {
customerName: activeCustomer?.tenantName || activeCustomer?.name || `Customer-${id(activeCustomer?.id)}`,
purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 采购商品`,
productSpecification: 'Phase4 acceptance', productUrl: null, purchaseQuantity: 10,
budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: 'Los Angeles',
budgetUnitPrice: 1.25, currency: 'USD', receivingAddress: '广东省深圳市南山区科技园测试路 1 号',
contactName: '采购验收联系人', contactPhone: '13800000002', remark: context.marker,
idempotencyKey: `${context.marker}-phase4-procurement`.slice(0, 255),
}
}
async function loginMerchant(context, activeCustomer) {
async function loginMerchant(context, activeCustomer, session) {
const fixture = context.context?.merchantQualificationFixture
if (!nonEmpty(fixture?.username) || !nonEmpty(fixture?.password)) {
return blocker(context, '采购客户登录前置完整', null,
@@ -97,6 +96,7 @@ async function loginMerchant(context, activeCustomer) {
const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS)
const token = auth?.accessToken
session.accessToken = token; session.refreshToken = auth?.refreshToken
context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token),
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS)
return nonEmpty(token) ? token : null
@@ -105,22 +105,10 @@ async function loginMerchant(context, activeCustomer) {
async function createMerchantProcurement(context, activeCustomer, token) {
const path = '/api/merchant/v1/last-leg/overseas-procurements'
const payload = procurementPayload(context, activeCustomer)
let activeToken = token
let response = await context.request('POST', path, payload, activeToken, {})
// A newly approved customer is projected to the overseas region asynchronously. The
// domestic login can therefore succeed a few seconds before the overseas gateway has
// received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact
// security-projection conflict and always issue a fresh short-lived overseas token.
for (let attempt = 1; attempt <= 12
&& response?.http === 403 && codeOf(response) === 'A0403'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const refreshedToken = await loginMerchant(context, activeCustomer)
if (!refreshedToken) break
activeToken = refreshedToken
response = await context.request('POST', path, payload, activeToken, {})
}
const activeToken = token
const response = await context.request('POST', path, payload, activeToken, {})
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
const value = summary(detail)
const value = detail?.order
const resource = nonEmpty(value?.orderNo) && longId(value?.customerId)
? { marker: context.marker, orderNo: id(value.orderNo), customerId: id(value.customerId),
version: Number(value.version), finalStatus: value.status, retained: true }
@@ -134,7 +122,7 @@ async function createMerchantProcurement(context, activeCustomer, token) {
const replayResponse = await context.request('POST', path, payload, activeToken, {})
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
const replay = summary(replayDetail)
const replay = replayDetail?.order
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version),
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
@@ -145,7 +133,7 @@ async function createMerchantProcurement(context, activeCustomer, token) {
return { resource, payload }
}
async function prerequisites(context) {
async function prerequisites(context, session) {
const tenants = await domesticGet(context, '查询 ACTIVE 客户候选', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
assertPage(context, 'ACTIVE 客户候选', tenants)
const activeCustomers = records(tenants.data).filter((item) => longId(item?.id) && item?.status === 'ACTIVE')
@@ -155,7 +143,7 @@ async function prerequisites(context) {
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
const token = await loginMerchant(context, activeCustomer)
const token = await loginMerchant(context, activeCustomer, session)
if (!token) return null
const partnerWarehouse = await createPartnerWarehouse(context)
if (!partnerWarehouse) return null
@@ -173,18 +161,17 @@ async function prerequisites(context) {
const procurementCustomer = Array.isArray(customerOptions.data)
? customerOptions.data.find((item) => id(item?.customerId) === id(activeCustomer.id)) : null
const warehouse = Array.isArray(warehouseOptions.data)
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id)
|| warehouseOptions.data.find((item) => longId(item?.warehouseId)) : null
? warehouseOptions.data.find((item) => id(item?.warehouseId) === partnerWarehouse.id) : null
if (!procurementCustomer) return blocker(context, '采购客户候选包含本轮客户', customerOptions.response,
`本轮客户 ${id(activeCustomer.id)} 未进入采购客户候选,禁止继续写验收`)
if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response,
'采购仓候选为空,禁止跳过采购三态清空')
'采购仓候选为空,禁止跳过采购备注与审核边界')
return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions,
partnerWarehouse, purchase, token }
}
function assertProcurementHistory(context, result) {
const histories = Array.isArray(result.data?.histories) ? result.data.histories : []
const histories = Array.isArray(result.data?.reviewHistory) ? result.data.reviewHistory : []
const operations = new Set(histories.map((item) => item?.operationType))
context.assert(MODULE, '采购流水覆盖创建编辑与审核', result.response,
['CREATE', 'UPDATE', 'REVIEW'].every((type) => operations.has(type))
@@ -207,55 +194,42 @@ async function procurementFlow(context, prerequisite) {
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}`, { customerId })
const detail = await overseas(context, 'GET', '查询本轮采购订单详情', path)
const before = summary(detail.data)
const before = detail.data?.customerView?.order
context.assert(MODULE, '采购详情归属和初始版本正确', detail.response,
before?.orderNo === owned.orderNo && id(before?.customerId) === customerId && Number(before?.version) === 0,
`orderNo=${id(before?.orderNo) || 'missing'}, version=${before?.version ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
const setResult = await overseas(context, 'PATCH', '采购可空字段设置非空值', path,
{ version: Number(before?.version), productSpecification: `${context.marker}-SPEC`,
productUrl: `phase4-product-${context.marker}`, purchaseWarehouseId: warehouseId,
purchaseWarehouseName: prerequisite.warehouse.warehouseName, domesticCarrier: `${context.marker}-CARRIER`,
domesticTrackingNo: `${context.marker}-TRACK`, estimatedArrivalAt: '2026-12-31T12:00:00', remark: context.marker })
const setSummary = summary(setResult.data)
context.assert(MODULE, '采购三态第一态设置值并递增版本', setResult.response,
setSummary?.productSpecification === `${context.marker}-SPEC`
&& detailValue(setResult.data, 'productUrl')?.includes(context.marker)
&& id(setSummary?.purchaseWarehouseId) === warehouseId
&& Number(setSummary?.version) === Number(before?.version) + 1,
`version=${setSummary?.version ?? 'missing'}, warehouseId=${id(setSummary?.purchaseWarehouseId) || 'missing'}; ${trace(setResult.response)}`, OPTIONS)
const setResult = await overseas(context, 'PATCH', '设置采购内部备注', path,
{ version: Number(before?.version), remark: `${context.marker}-edited` })
const setSummary = setResult.data?.summary
context.assert(MODULE, '采购备注设置并递增版本', setResult.response,
setResult.data?.remark === `${context.marker}-edited` && Number(setSummary?.version) === Number(before?.version) + 1,
trace(setResult.response), OPTIONS)
const omittedResult = await overseas(context, 'PATCH', '采购可空字段缺失保持原值', path,
{ version: Number(setSummary?.version), domesticCarrier: `${context.marker}-CARRIER-2` })
const omittedSummary = summary(omittedResult.data)
context.assert(MODULE, '采购三态第二态字段缺失不修改', omittedResult.response,
omittedSummary?.productSpecification === setSummary?.productSpecification
&& detailValue(omittedResult.data, 'productUrl') === detailValue(setResult.data, 'productUrl')
&& id(omittedSummary?.purchaseWarehouseId) === warehouseId
&& Number(omittedSummary?.version) === Number(setSummary?.version) + 1,
`version=${omittedSummary?.version ?? 'missing'}, specification=${omittedSummary?.productSpecification || 'missing'}; ${trace(omittedResult.response)}`, OPTIONS)
const clearedResult = await overseas(context, 'PATCH', '采购可空字段显式 null 与空串清空', path,
{ version: Number(omittedSummary?.version), productSpecification: null, productUrl: '', purchaseWarehouseId: null,
purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: '', estimatedArrivalAt: null, remark: null })
const clearedSummary = summary(clearedResult.data)
context.assert(MODULE, '采购三态第三态显式清空全部生效', clearedResult.response,
clearedSummary?.productSpecification == null && detailValue(clearedResult.data, 'productUrl') == null
&& clearedSummary?.purchaseWarehouseId == null && clearedSummary?.purchaseWarehouseName == null
&& detailValue(clearedResult.data, 'domesticCarrier') == null
&& detailValue(clearedResult.data, 'domesticTrackingNo') == null
&& detailValue(clearedResult.data, 'estimatedArrivalAt') == null
&& Number(clearedSummary?.version) === Number(omittedSummary?.version) + 1,
`version=${clearedSummary?.version ?? 'missing'}, specification=${clearedSummary?.productSpecification ?? 'null'}, warehouseId=${clearedSummary?.purchaseWarehouseId ?? 'null'}; ${trace(clearedResult.response)}`, OPTIONS)
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' })
const omittedResult = await overseas(context, 'PATCH', '采购备注缺失保持原值', path,
{ version: Number(setSummary?.version) })
context.assert(MODULE, '缺失备注不修改且版本不漂移', omittedResult.response,
omittedResult.data?.remark === `${context.marker}-edited` && Number(omittedResult.data?.summary?.version) === Number(setSummary?.version),
trace(omittedResult.response), OPTIONS)
const clearedResult = await overseas(context, 'PATCH', '采购备注显式清空', path,
{ version: Number(setSummary?.version), remark: null })
const clearedSummary = clearedResult.data?.summary
context.assert(MODULE, '采购备注清空只变更本字段', clearedResult.response,
clearedResult.data?.remark == null && Number(clearedSummary?.version) === Number(setSummary?.version) + 1,
trace(clearedResult.response), OPTIONS)
const forbidden = await context.request('PATCH', path,
{ version: Number(clearedSummary?.version), domesticTrackingNo: 'FORBIDDEN' })
context.assert(MODULE, '通用编辑拒绝旧履约字段', forbidden,
forbidden?.http === 400 || (forbidden?.http === 200 && codeOf(forbidden) === 'A0400'), trace(forbidden), OPTIONS)
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), remark: 'STALE-DO-NOT-APPLY' })
expectedConflict(context, '采购旧 version 返回 A0409', stale, true)
const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path)
const afterConflictSummary = summary(afterConflict.data)
context.assert(MODULE, '采购旧 version 冲突未造成部分更新', afterConflict.response,
afterConflictSummary?.productSpecification == null
const afterConflictSummary = afterConflict.data?.customerView?.order
context.assert(MODULE, '采购拒绝请求不改变商品与版本', afterConflict.response,
afterConflictSummary?.productSpecification === before?.productSpecification
&& afterConflict.data?.internalRemark == null
&& Number(afterConflictSummary?.version) === Number(clearedSummary?.version),
`version=${afterConflictSummary?.version ?? 'missing'}, specification=${afterConflictSummary?.productSpecification ?? 'null'}; ${trace(afterConflict.response)}`, OPTIONS)
trace(afterConflict.response), OPTIONS)
const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态',
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
@@ -279,12 +253,12 @@ async function procurementFlow(context, prerequisite) {
const reviewHashConflict = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
const reverseReview = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { version: Number(rejectedSummary?.version), decision: 'APPROVE',
idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
purchaseWarehouseId: warehouseId, idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
remark: 'REVERSE-DO-NOT-APPLY' })
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
const final = await overseas(context, 'GET', '采购最终状态与流水回查', path)
const finalSummary = summary(final.data)
const finalSummary = final.data?.customerView?.order
context.assert(MODULE, '采购 marker 资源最终保持合法终态', final.response,
finalSummary?.status === 'REJECTED' && Number(finalSummary?.version) === Number(rejectedSummary?.version),
`status=${finalSummary?.status || 'missing'}, version=${finalSummary?.version ?? 'missing'}; ${trace(final.response)}`, OPTIONS)
@@ -536,8 +510,8 @@ async function finalizePendingPurchase(context, resource) {
const path = query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}`,
{ customerId: resource.customerId })
const current = await overseas(context, 'GET', '采购最终处置前回查本轮订单', path)
const currentSummary = summary(current.data)
if (['REJECTED', 'APPROVED'].includes(currentSummary?.status)) {
const currentSummary = current.data?.customerView?.order
if (['REJECTED', 'CANCELLED'].includes(currentSummary?.status)) {
resource.finalStatus = currentSummary.status; resource.version = Number(currentSummary.version); resource.retained = false
return
}
@@ -555,7 +529,7 @@ async function finalizePendingPurchase(context, resource) {
remark: `${context.marker}-prerequisite-cleanup` })
const rejectedSummary = summary(rejected.data)
const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path)
const finalSummary = summary(verified.data)
const finalSummary = verified.data?.customerView?.order
const passed = rejectedSummary?.status === 'REJECTED' && finalSummary?.status === 'REJECTED'
&& Number(finalSummary?.version) === Number(rejectedSummary?.version)
context.assert(MODULE, '采购本轮测试数据最终合法驳回', verified.response, passed,
@@ -567,8 +541,9 @@ async function finalizePendingPurchase(context, resource) {
export async function run(context) {
let prerequisite = null
const session = {}
try {
prerequisite = await prerequisites(context)
prerequisite = await prerequisites(context, session)
if (!prerequisite) return
await procurementFlow(context, prerequisite)
const customerId = id(prerequisite.activeCustomer.id)
@@ -576,7 +551,16 @@ export async function run(context) {
await looseboardFlow(context, customerId)
} finally {
const registry = context.createdResources.phase4OverseasBusiness
await finalizePendingPurchase(context, registry?.purchase)
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
try {
await finalizePendingPurchase(context, registry?.purchase)
await retirePartnerWarehouse(context, registry?.partnerWarehouse)
} finally {
if (nonEmpty(session.refreshToken)) {
const loggedOut = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken || '')
context.apiSuccess(MODULE, '退出本轮采购客户会话', loggedOut, trace(loggedOut), OPTIONS)
} else if (nonEmpty(session.accessToken)) {
blocker(context, '采购客户会话退出凭据缺失', null, '登录响应缺少 refreshToken,无法确认会话退出')
}
}
}
}
@@ -55,6 +55,7 @@ function fixture() {
}
if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/finance-fee-sources?')) return response([])
if (method === 'POST' && path === '/api/platform/v1/last-leg/finance-fee-sources') {
if (body.businessType === 'OVERSEAS_PROCUREMENT') return response(null, 'A0409')
const key = headers['Idempotency-Key']
const hash = JSON.stringify(body)
const existing = commands.get(`CREATE:${key}`)
@@ -134,13 +135,13 @@ test('module uses only injected real HTTP clients and never fabricates database/
]) assert.ok(source.includes(token), token)
})
test('five fee types traverse Lastleg lifecycle and reach Finance through real HTTP', async () => {
test('four generic fee types publish while procurement bypass is rejected', async () => {
const context = fixture()
await run(context)
const registry = context.createdResources.phase4OverseasFinanceCrossRegion
assert.equal(registry.sources.length, 5)
assert.equal(registry.sources.length, 4)
assert.deepEqual(new Set(registry.sources.map((item) => item.businessType)), new Set([
'OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE',
'OVERSEAS_INBOUND', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE',
]))
const warehouseCreate = context.calls.find((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources'
@@ -163,15 +164,16 @@ test('five fee types traverse Lastleg lifecycle and reach Finance through real H
const creates = context.calls.filter((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources')
assert.equal(creates.length, 12)
assert.equal(creates.length, 11)
assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed)
assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed)
assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed)
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).length, 5)
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).length, 4)
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).every((step) => step.passed))
assert.ok(context.steps.find((step) => step.name === '统一Gateway不公开内部事件运维接口')?.passed)
assert.deepEqual(context.steps.filter((step) => !step.passed), [])
assert.ok(context.steps.find(step => step.name === '采购费用禁止绕过履约实扣创建通用来源')?.passed)
assert.equal(context.scenarios[0].deploymentBlocked, false)
assert.equal(context.scenarios[0].success, true)
})
@@ -222,17 +222,22 @@ export async function run(context) {
const fixtures = await resolveFixtures(context)
const definitions = [
{ label: '海外入仓', businessType: 'OVERSEAS_INBOUND', sourceType: 'OVERSEAS_INBOUND_FEE', fixture: fixtures.inbound },
{ label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', fixture: fixtures.procurement },
{ label: '美国卡车', businessType: 'US_TRUCK', sourceType: 'OVERSEAS_TRUCK_FEE', fixture: fixtures.truck },
{ label: '本土散板', businessType: 'LOCAL_LOOSEBOARD', sourceType: 'OVERSEAS_LOOSEBOARD_FEE', fixture: fixtures.looseboard },
{ label: '海外仓服务', businessType: 'WAREHOUSE_SERVICE', sourceType: 'OVERSEAS_WAREHOUSE_FEE', fixture: fixtures.warehouse },
]
if (fixtures.procurement) {
const definition = { label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE' }
const denied = await context.request('POST', LASTLEG_BASE, feePayload(context,definition,fixtures.procurement,0), undefined,
{ 'Idempotency-Key': `${context.marker}-procurement-bypass-denied`.slice(0,128) })
context.assert(MODULE, '采购费用禁止绕过履约实扣创建通用来源', denied, conflict(denied), trace(denied), OPTIONS)
}
const sources = []
for (const [index, definition] of definitions.entries()) {
const source = await createApprove(context, definition, definition.fixture, index)
if (source) sources.push(source)
}
context.assert(MODULE, '五类海外费用均创建、提交并审核', null, sources.length === definitions.length,
context.assert(MODULE, '四类通用海外费用均创建、提交并审核', null, sources.length === definitions.length,
`created=${sources.length}, expected=${definitions.length}`, OPTIONS)
const warehouseSource = sources.find((item) => item.definition.businessType === 'WAREHOUSE_SERVICE')
context.assert(MODULE, '仓储服务费用仅关联客户和仓库', null,
@@ -21,10 +21,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
'creditExpiresOn',
'/accounts/${encodeURIComponent(customerId)}/adjustments',
"const CLIENT_FINANCE = '/api/client/finance/v1'",
"['VOUCHER', 'RECEIPT']",
"walletStatus === 'FROZEN'",
"walletStatus === 'SETTLED'",
"['FREEZE_PENDING', 'FROZEN']",
"['FREEZE', 'CONSUMPTION', 'UNFREEZE']",
'item.sourceNo === businessNo',
"'RELEASED'",
@@ -44,8 +41,13 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
"deliveryMethod: 'TRUCK'",
]) assert.ok(source.includes(token), token)
assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/)
assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/)
assert.doesNotMatch(source, /voucherFileId:\s*Number\(/)
assert.match(source, /verifyProcurementSample\(procurementContext/)
const sample = readFileSync(new URL('./procurement-sample-fulfillment.mjs', import.meta.url), 'utf8')
assert.match(sample, /walletStatus === 'SETTLED'/)
assert.match(sample, /sourceLinked/)
assert.match(source, /Number\(procurement.settledAmountCny\) - 0.01/)
assert.match(source, /procurement.walletBusinessNos/)
assert.doesNotMatch(source, /inboundQuantity|PENDING_OUTBOUND/)
assert.doesNotMatch(source, /customerOrderNo:[^\n]+overseasWarehouseId:/)
assert.doesNotMatch(source, /internal\/finance|jdbc|mysql|process\.env|https?:\/\//i)
})
@@ -1,3 +1,6 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
const MODULE = 'phase4-overseas-wallet-and-pallet-cost'
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -47,9 +50,9 @@ function assert(context, name, response, passed, evidence = '') {
async function merchantLogin(context, fixture) {
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
const auth = success(response) ? response.body.data : null
assert(context, '客户账号同时取得国内和海外短时Token', response,
Boolean(auth?.accessToken) && Boolean(auth?.accessToken),
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`)
assert(context, '客户账号取得统一会话与退出凭据', response,
Boolean(auth?.accessToken) && Boolean(auth?.refreshToken),
`customerId=${fixture.tenantId}, session=${Boolean(auth?.accessToken)}, refresh=${Boolean(auth?.refreshToken)}`)
return auth
}
@@ -96,79 +99,46 @@ async function prepareWallet(context, customerId) {
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
}
async function procurementFlow(context, fixture, token) {
const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements'
const payload = {
customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`,
productSpecification: 'full acceptance wallet', productUrl: null, purchaseQuantity: 2,
budgetUnitPrice: 1, currency: 'USD', receivingAddress: 'Los Angeles',
contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker,
idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255),
async function procurementFlow(context, fixture, token, warehouseId) {
if (!/^[A-Za-z0-9_-]{1,80}$/.test(context.marker)) throw new Error('采购验收批次不适合作为独立证据标识')
const directory = new URL('../../reports/procurement-wallet-checkpoints/', import.meta.url)
await mkdir(directory, { recursive: true })
const output = new URL(`${context.marker}.json`, directory)
const procurementContext = Object.create(context)
let initialized = false
procurementContext.checkpoint = async () => {
const proof = context.createdResources.procurementSample || { marker: context.marker, status: 'PREPARING' }
await writeFile(output, JSON.stringify(proof, null, 2) + '\n', { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
}
const created = await context.request('POST', merchantPath, payload, token, {})
const detail = created?.body?.data
const orderNo = detail?.summary?.orderNo
assert(context, '客户创建钱包采购单', created,
success(created) && id(detail?.summary?.customerId) === id(fixture.tenantId) && detail?.summary?.status === 'PENDING_REVIEW',
`orderNo=${orderNo || 'missing'}, status=${detail?.summary?.status || 'missing'}`)
if (!success(created) || !orderNo) return null
assert(context, '采购单号按客户拼音首字母加北京时间日期和四位序号生成', created,
fixture.expectedProcurementPrefix ? matchesProcurementNo(orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(orderNo),
`orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`)
await procurementContext.checkpoint()
const customerId = id(fixture.tenantId)
const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
version: Number(detail.summary.version), decision: 'APPROVE',
idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker,
})
assert(context, '平台审核通过钱包采购单', approved,
success(approved) && approved.body.data?.summary?.status === 'APPROVED',
`status=${approved.body?.data?.summary?.status || 'missing'}`)
if (!success(approved)) return null
const files = []
for (const category of ['VOUCHER', 'RECEIPT']) {
const uploaded = await context.requestMultipart('POST',
query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
assert(context, `采购单上传${category}文件`, uploaded,
success(uploaded) && id(uploaded.body.data?.fileId) !== '' && uploaded.body.data?.category === category,
`fileId=${id(uploaded.body?.data?.fileId) || 'missing'}`)
if (success(uploaded)) files.push(uploaded.body.data)
const upload = async (orderNo, category) => {
const proof = context.createdResources.procurementSample
const entry = { category, orderNo, status: 'SENDING' }
proof.uploads ??= []; proof.uploads.push(entry); await procurementContext.checkpoint()
let response
try {
response = await context.requestMultipart('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
} catch (error) { entry.status = 'UNKNOWN_RESULT'; await procurementContext.checkpoint(); throw error }
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, code: response?.body?.code, fileId: response?.body?.data?.fileId, requestId: response?.requestId, traceId: response?.traceId })
await procurementContext.checkpoint()
assert(context, `采购受控上传${category}`, response, success(response) && Boolean(response.body.data?.fileId))
if (!success(response) || !response.body.data?.fileId) throw new Error('采购凭证上传未确认成功,不自动重试')
return response.body.data
}
try {
const proof = await verifyProcurementSample(procurementContext, { marker: context.marker, customerId,
customerName: fixture.tenantName, customerToken: token, warehouseId, upload,
readWallet: async () => { const result = await platformAccount(context, customerId); if (!success(result.response) || !result.account) throw new Error('本轮客户钱包查询失败'); return result.account } })
assert(context, '采购正常全链路及资金完成', null, proof.status === 'PASSED', `orderNo=${proof.orderNo}`)
assert(context, '采购单号日期和客户前缀正确', null,
fixture.expectedProcurementPrefix ? matchesProcurementNo(proof.orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(proof.orderNo), `orderNo=${proof.orderNo}`)
return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny }
} finally {
for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)
}
if (files.length !== 2) return null
const fee = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), {
feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01,
voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker,
})
const feeNo = fee?.body?.data?.feeNo
assert(context, '采购费用创建并开始钱包冻结', fee,
success(fee) && Boolean(feeNo) && ['FREEZE_PENDING', 'FROZEN'].includes(fee.body.data?.walletStatus),
`feeNo=${feeNo || 'missing'}, walletStatus=${fee.body?.data?.walletStatus || 'missing'}`)
if (!success(fee) || !feeNo) return null
const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId })
const frozen = await poll(async () => context.request('GET', detailPath),
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN')
const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
assert(context, '采购费用跨区冻结到账', frozen.result,
frozenFee?.walletStatus === 'FROZEN', `feeNo=${feeNo}, pollRequests=${frozen.attempts}, walletStatus=${frozenFee?.walletStatus || 'missing'}`)
if (frozenFee?.walletStatus !== 'FROZEN') return null
const current = frozen.result.body.data.summary
const settledOrder = await context.request('PATCH', detailPath, {
version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity),
})
assert(context, '采购全量入库推进待出库并触发结算', settledOrder,
success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND',
`status=${settledOrder.body?.data?.summary?.status || 'missing'}`)
const settled = await poll(async () => context.request('GET', detailPath),
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED')
const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
assert(context, '采购费用跨区结算到账', settled.result,
settledFee?.walletStatus === 'SETTLED', `feeNo=${feeNo}, pollRequests=${settled.attempts}, walletStatus=${settledFee?.walletStatus || 'missing'}`)
return { orderNo, feeNo, freezePollRequests: frozen.attempts, settlePollRequests: settled.attempts }
}
function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
@@ -276,7 +246,7 @@ async function relabelFlow(context, token, warehouseId, suffix, terminalStatus)
async function verifyClientWallet(context, token, fixture, wallet, procurement, relabels) {
const account = await context.request('GET', `${CLIENT_FINANCE}/account`, undefined, token)
const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - 0.02)
const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - Number(procurement.settledAmountCny) - 0.01)
assert(context, '客户工作台余额与冻结结算结果一致', account,
success(account) && money(account.body.data?.actualBalance) === expectedActual
&& money(account.body.data?.frozenBalance) === money(wallet.before.frozenBalance)
@@ -284,7 +254,7 @@ async function verifyClientWallet(context, token, fixture, wallet, procurement,
`expectedActual=${expectedActual}, actual=${account.body?.data?.actualBalance ?? 'missing'}, frozen=${account.body?.data?.frozenBalance ?? 'missing'}, creditUsed=${account.body?.data?.creditUsed ?? 'missing'}`)
const ledger = await context.request('GET', `${CLIENT_FINANCE}/account/ledger?page=1&size=100`, undefined, token)
const entries = rows(ledger?.body?.data)
const businessNos = [procurement.feeNo, ...relabels.map((item) => item.relabelNo)]
const businessNos = [...procurement.walletBusinessNos, ...relabels.map((item) => item.relabelNo)]
assert(context, '客户账本包含充值、冻结、结算和释放明细', ledger,
success(ledger) && entries.some((item) => item.changeType === 'RECHARGE')
&& businessNos.every((businessNo) => entries.some((item) => item.sourceNo === businessNo))
@@ -357,8 +327,10 @@ export async function run(context) {
if (!fixture?.tenantId || !warehouseId || completedFacts.length === 0) return
const auth = await merchantLogin(context, fixture)
if (!auth?.accessToken || !auth?.refreshToken) return
try {
const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken) : null
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken, warehouseId) : null
const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
const relabels = [settledRelabel, releasedRelabel].filter(Boolean)
@@ -374,4 +346,9 @@ export async function run(context) {
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
assert(context, '退出本轮钱包客户会话', logout, success(logout))
}
}
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
function fixture() {
const events = []
const context = { marker: 'PROC-TEST', token: 'admin-token', createdResources: {},
async checkpoint() { events.push('checkpoint') },
async request(method, path) { events.push(`${method} ${path}`); throw new Error('network outcome unknown') } }
const inputs = { marker: context.marker, customerId: '101', warehouseId: '201', customerName: 'QA', customerToken: 'customer-token',
async upload() { throw new Error('must not upload yet') },
async readWallet() { return { actualBalance: '10.00', availableBalance: '10.00', frozenBalance: '0.00', creditUsed: '0.00' } } }
return { context, inputs, events }
}
test('missing owned fixture is rejected before any HTTP', async () => {
const { context, inputs, events } = fixture()
inputs.marker = 'other-run'
await assert.rejects(verifyProcurementSample(context, inputs), /本轮独立客户/)
assert.deepEqual(events, [])
})
test('insufficient independent cash stops before creating an order', async () => {
const { context, inputs, events } = fixture()
inputs.readWallet = async () => ({ actualBalance: '1.00', availableBalance: '1.00', frozenBalance: '0.00', creditUsed: '0.00' })
await assert.rejects(verifyProcurementSample(context, inputs), /现金余额/)
assert.equal(events.some(event => event.startsWith('POST')), false)
assert.equal(context.createdResources.procurementSample.status, 'FAILED')
})
test('unknown create outcome is persisted and never automatically retried', async () => {
const { context, inputs, events } = fixture()
await assert.rejects(verifyProcurementSample(context, inputs), /network outcome unknown/)
const proof = context.createdResources.procurementSample
assert.equal(proof.requests.length, 1)
assert.equal(proof.requests[0].status, 'UNKNOWN_RESULT')
assert.ok(proof.requests[0].body.idempotencyKey)
assert.equal(events.filter(event => event.startsWith('POST')).length, 1)
const write = events.findIndex(event => event.startsWith('POST'))
assert.equal(events[write - 1], 'checkpoint')
assert.equal(JSON.stringify(proof).includes('customer-token'), false)
await assert.rejects(verifyProcurementSample(context, inputs), /不自动重跑/)
})
test('failure to persist the request prevents network dispatch', async () => {
const { context, inputs, events } = fixture()
context.checkpoint = async () => { throw new Error('disk write failed') }
await assert.rejects(verifyProcurementSample(context, inputs), /disk write failed/)
assert.equal(events.length, 0)
})
@@ -0,0 +1,125 @@
const MODULE = 'procurement-sample-fulfillment'
const PLATFORM = '/api/platform/v1/last-leg/overseas-procurements'
const MERCHANT = '/api/merchant/v1/last-leg/overseas-procurements'
const clone = value => value == null ? value : structuredClone(value)
const cents = value => {
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value))
if (!match) throw new Error('钱包金额缺失或精度异常')
return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
}
/** Internal scenario; the TEST coordinator owns isolated customer creation, funding, sessions and file upload. */
export async function verifyProcurementSample(context, inputs) {
if (!context.marker || typeof context.checkpoint !== 'function' || typeof context.request !== 'function'
|| inputs?.marker !== context.marker || !/^[1-9]\d*$/.test(String(inputs.customerId))
|| !/^[1-9]\d*$/.test(String(inputs.warehouseId)) || !inputs.customerToken
|| typeof inputs.upload !== 'function' || typeof inputs.readWallet !== 'function')
throw new Error('缺少本轮独立客户、仓库、会话、上传或持久化验收前置')
if (context.createdResources.procurementSample) throw new Error('已有采购验收证据,不自动重跑或覆盖')
const proof = context.createdResources.procurementSample = { marker: context.marker, status: 'RUNNING', requests: [], checks: [], orderNo: null }
const check = async (label, accepted) => {
proof.checks.push({ label, passed: Boolean(accepted) }); await context.checkpoint()
if (!accepted) throw new Error(`${MODULE}: ${label}`)
}
const customerId = String(inputs.customerId)
let detail, sequence = 0
const at = () => {
const value = detail?.customerView?.order?.updatedAt
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) throw new Error('服务端业务时间缺失')
return value
}
const date = () => at().slice(0, 10)
const key = label => `${context.marker}-${++sequence}-${label}`.slice(0, 128)
const path = (operation = '') => `${PLATFORM}/${encodeURIComponent(proof.orderNo)}${operation ? '/' + operation : ''}?customerId=${encodeURIComponent(customerId)}`
async function request(method, url, body, label, token = context.token, expectedCode = '200') {
const entry = { method, path: url, body: clone(body), label, status: 'SENDING' }
proof.requests.push(entry); await context.checkpoint()
let response
try { response = await context.request(method, url, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint(); throw error }
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, code: String(response?.body?.code), requestId: response?.requestId, traceId: response?.traceId })
await context.checkpoint()
await check(label, response?.http === 200 && String(response?.body?.code) === expectedCode)
return response.body.data
}
async function read() { detail = await request('GET', path(), undefined, '回查采购聚合详情'); return detail }
async function command(operation, values = {}, replay = false) {
const body = { version: detail.customerView.order.version, idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values }
await request('POST', path(operation), body, operation)
if (replay) await request('POST', path(operation), body, `${operation} 原请求重放`)
await read(); return body
}
async function waitFor(label, predicate) {
for (let attempt = 0; attempt < 31; attempt++) {
await read()
if (predicate(detail)) return
if (attempt < 30) await new Promise(resolve => setTimeout(resolve, 2000))
}
await check(`${label}在限定时间内完成`, false)
}
const fee = purpose => detail.customerView.fees.find(row => row.active && row.purpose === purpose)
async function wallet(label, spent, frozen) {
const current = await inputs.readWallet()
proof.walletChecks ??= []; proof.walletChecks.push({ label, account: clone(current) })
await check(label, cents(current.actualBalance) === cents(proof.walletBefore.actualBalance) - spent
&& cents(current.frozenBalance) === cents(proof.walletBefore.frozenBalance) + frozen
&& cents(current.creditUsed) === cents(proof.walletBefore.creditUsed))
}
try {
proof.walletBefore = clone(await inputs.readWallet())
await check('客户现金余额足以完成独立样本', cents(proof.walletBefore.availableBalance) >= 500n)
const recipient = { name: '采购样品验收', phone: '13800000002', address: '广东省深圳市南山区科技园测试路1号' }
const create = { customerName: inputs.customerName, purchaseCategory: 'SAMPLE', productName: `${context.marker} sample`, productSpecification: 'one-spec', purchaseQuantity: 2, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: recipient.address, contactName: recipient.name, contactPhone: recipient.phone, idempotencyKey: key('create') }
const created = await request('POST', MERCHANT, create, '客户创建样品订单', inputs.customerToken)
proof.orderNo = created?.order?.orderNo; await context.checkpoint()
await check('客户创建响应包含本轮订单', typeof proof.orderNo === 'string' && created.order.customerId === customerId)
const repeated = await request('POST', MERCHANT, create, '客户同键重放', inputs.customerToken)
await check('重放没有新建采购单', repeated.order.orderNo === proof.orderNo)
await read()
await request('POST', path('review'), { version: detail.customerView.order.version, idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓')
await read()
const purchaseFile = await inputs.upload(proof.orderNo, 'PURCHASE')
const voucher = await inputs.upload(proof.orderNo, 'VOUCHER')
const labelFile = await inputs.upload(proof.orderNo, 'LABEL')
await check('凭证上传返回正式文件引用', [purchaseFile, voucher, labelFile].every(file => /^[1-9]\d*$/.test(String(file.fileId))))
await command('purchase', { productName: create.productName, specification: create.productSpecification, quantity: 2, amount: '1.00', currency: 'CNY', purchasedAt: at(), estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0,19), voucherFileId: String(purchaseFile.fileId) }, true)
await waitFor('样品货款实扣及来源关联', () => fee('SAMPLE_PAYMENT')?.walletStatus === 'SETTLED' && fee('SAMPLE_PAYMENT')?.sourceLinked)
await wallet('购买完成扣货款1元', 100n, 0n)
for (let count = 0; count < 2; count++) await command('overseas-receipts', { leg: 'OVERSEAS', warehouseId: String(inputs.warehouseId), goodQuantity: 1, damagedQuantity: 0, extraQuantity: 0, receivedAt: at() }, true)
await check('分次收货重放不增加数量', detail.physical.receipts.filter(row => row.leg === 'OVERSEAS' && row.active).reduce((sum,row) => sum + Number(row.goodQuantity),0) === 2)
await command('overseas-receipts/complete', { leg: 'OVERSEAS' })
const receipts = detail.physical.receipts.filter(row => row.leg === 'OVERSEAS' && row.active)
await command('packing', { cartons: receipts.map((row,index) => ({ cartonNo: `${context.marker}-C${index+1}`, lengthCm: '10.00', widthCm: '10.00', heightCm: '10.00', grossWeightKg: '1.000', sources: [{ receiptId: String(row.id), quantity: 1 }] })) })
await command('packing/confirm', { packingVersion: detail.physical.packingVersion })
const cartonIds = detail.physical.cartons.map(row => String(row.id))
async function labels(leg) {
await command('labels', { leg, packingVersion: detail.physical.packingVersion, receiver: recipient, labels: cartonIds.map((cartonId,index) => ({ cartonId, fileId: String(labelFile.fileId), page: 1, carrier: 'TEST-EXPRESS', trackingNo: `${context.marker}-${leg}-${index+1}` })) })
return detail.transport[leg === 'OVERSEAS' ? 'overseasLabels' : 'domesticLabels'].version
}
const internationalLabels = await labels('OVERSEAS')
await command('international-estimate', { labelVersion: internationalLabels, amount: '2.00', businessDate: date() }, true)
await waitFor('预估冻结', () => fee('INTERNATIONAL_ESTIMATE')?.walletStatus === 'FROZEN')
await wallet('预估2元只冻结不消费', 100n, 200n)
await command('labels/apply', { labelVersion: internationalLabels, cartonIds, appliedAt: at() })
await command('overseas-shipment', { leg: 'OVERSEAS', labelVersion: internationalLabels, cartonIds, shippedAt: at() }, true)
await check('国际段只有一次实际交寄', detail.transport.shipments.filter(row => row.leg === 'OVERSEAS').length === 1)
for (const sourceCartonId of cartonIds) await command('domestic-receipts', { leg: 'DOMESTIC', sourceCartonId, goodQuantity: 1, damagedQuantity: 0, extraQuantity: 0, receivedAt: at() }, true)
await command('domestic-receipts/complete', { leg: 'DOMESTIC' })
const actual = amount => ({ value: { determination: amount === '0.00' ? 'ZERO' : 'POSITIVE', amount, currency: 'CNY', businessDate: date(), voucherFileId: String(voucher.fileId), reason: context.marker } })
await command('charges/confirm', { internationalFreight: actual('1.50'), duty: actual('0.00'), domesticFreight: actual('0.00'), otherDetermination: 'NOT_APPLICABLE', otherFees: [] }, true)
await waitFor('实际费用实扣及来源关联', () => fee('INTERNATIONAL_FREIGHT')?.walletStatus === 'SETTLED' && fee('INTERNATIONAL_FREIGHT')?.sourceLinked)
await wallet('实际国际运费扣1.5元并释放剩余冻结', 250n, 0n)
const domesticLabels = await labels('DOMESTIC')
await command('customer-shipment', { leg: 'DOMESTIC', labelVersion: domesticLabels, cartonIds, shippedAt: at() }, true)
await check('寄客户正常终态及单次交寄', detail.customerView.order.status === 'SHIPPED_TO_CUSTOMER' && detail.transport.shipments.filter(row => row.leg === 'DOMESTIC').length === 1)
const customer = await request('GET', `${MERCHANT}/${encodeURIComponent(proof.orderNo)}`, undefined, '客户最终详情', inputs.customerToken)
await check('客户可见终态且没有平台内部数据', customer.order.status === 'SHIPPED_TO_CUSTOMER' && !['files','purchaseFacts','controls','internalRemark','operations'].some(field => Object.hasOwn(customer,field)))
await wallet('重复交寄不再次扣款', 250n, 0n)
proof.walletBusinessNos = [...new Set(detail.fees.filter(row => row.walletStatus === 'SETTLED').map(row => row.walletBusinessNo))]
await check('每笔成功消费保留权威钱包业务引用', proof.walletBusinessNos.length === 2 && proof.walletBusinessNos.every(value => typeof value === 'string' && value.length > 0))
proof.settledAmountCny = '2.50'
proof.status = 'PASSED'; await context.checkpoint(); return proof
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error
}
}
+111
View File
@@ -0,0 +1,111 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { parseEnv } from 'node:util'
import { resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { run as prepareCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
import { verifyProcurementSample } from './admin-test-env-modules/procurement-sample-fulfillment.mjs'
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
export function options(args) {
const value = name => { const index = args.indexOf(name); return index < 0 ? '' : args[index + 1] || '' }
if (!args.includes('--execute')) return { execute: false }
const runId = value('--run-id'), warehouseId = value('--warehouse-id')
if (value('--environment') !== 'TEST' || !/^[A-Za-z0-9-]{1,32}$/.test(runId) || !/^[1-9]\d*$/.test(warehouseId)) throw new Error('须显式指定 TEST、唯一 run-id 和已核对的有效收货仓')
return { execute: true, runId, warehouseId }
}
export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) {
const config = options(args)
if (!config.execute) return { mode: 'PLAN', writes: ['创建独立客户并审核资质', '仅为本轮客户充值10元', '上传样品凭证和面单', '执行采购两箱完整正常流程'], sql: false, deploy: false, cleanup: '保留样本,退出自身会话;未知结果不自动重跑' }
const flag = execArgs.find(value => value.startsWith('--env-file='))
const envPath = flag ? resolve(flag.slice('--env-file='.length)) : ''
if (!envPath.replaceAll('\\','/').endsWith('/.codex/env/chongchong-global/test-acceptance.env')) throw new Error('脚本前必须显式选择授权 TEST 环境文件')
const env = parseEnv(await readFile(envPath, 'utf8'))
if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw new Error('TEST身份配置缺失')
const gateway = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
if (!['http:', 'https:'].includes(gateway.protocol) || gateway.pathname.replace(/\/$/,'') !== '/api' || gateway.username || gateway.password || gateway.search || gateway.hash) throw new Error('TEST统一入口格式不符')
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..')
if (!repository.replaceAll('\\','/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('只能在 TEST 工作区执行')
const output = resolve(repository, `reports/procurement-sample-${config.runId}/report.json`)
await mkdir(dirname(output), { recursive: true })
const context = new AcceptanceRun({ gateway: gateway.origin, environment: 'test', quiet: true, modules: [] })
context.marker = `PROC-${config.runId}`
const report = { marker: context.marker, status: 'PREPARING', startedAt: new Date().toISOString(), requests: [], sessions: [], resources: context.createdResources, steps: context.steps }
const secrets = new Set([env.CHONGCHONG_ADMIN_PASSWORD])
const sessions = []
function safe(value) {
if (Array.isArray(value)) return value.map(safe)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key,item]) => [key, /password|token|loginId/i.test(key) ? '[REDACTED]' : safe(item)]))
if (typeof value === 'string') { for (const secret of secrets) if (secret && secret.length >= 8) value = value.replaceAll(secret, '[REDACTED]') }
return value
}
async function checkpoint(first = false) { await writeFile(output, JSON.stringify(safe(report), null, 2) + '\n', { encoding: 'utf8', flag: first ? 'wx' : 'w' }) }
await checkpoint(true)
context.checkpoint = checkpoint
const rawRequest = context.request.bind(context), rawForm = context.requestWithBody.bind(context)
async function recorded(send, method, path, body, token, headers) {
const entry = { method, path, body: body instanceof FormData ? { multipart: true, fields: [...body.keys()] } : safe(body), status: 'SENDING' }
report.requests.push(entry); await checkpoint()
let response
try { response = await send(method, path, body, token, headers) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; await checkpoint(); throw error }
for (const field of ['accessToken','refreshToken','temporaryPassword']) if (response?.body?.data?.[field]) secrets.add(response.body.data[field])
const unknown = response?.http === 0 || response?.http >= 500 || !response?.body
Object.assign(entry, { status: unknown ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await checkpoint()
if (unknown) throw new Error('请求结果未知,已保留原请求,禁止自动重跑')
return response
}
context.request = (method,path,body,token=context.token,headers={}) => recorded(rawRequest,method,path,body,token,headers)
context.requestWithBody = (method,path,body,token=context.token,headers={}) => recorded(rawForm,method,path,body,token,headers)
const requireSuccess = response => {
if (response?.http !== 200 || String(response?.body?.code) !== '200') throw new Error(`业务请求失败:${response?.http}/${response?.body?.code}`)
return response.body.data
}
async function login(username,password,actor) {
const data = requireSuccess(await context.request('POST','/api/auth/login',{loginId:username,password},''))
if (!data?.accessToken || !data?.refreshToken) throw new Error('登录响应缺少会话引用')
sessions.push({ actor, accessToken: data.accessToken, refreshToken: data.refreshToken })
report.sessions.push({ actor, login: 'SUCCESS', logout: 'NOT_STARTED' }); await checkpoint()
return data.accessToken
}
let failed = false
try {
context.token = await login(env.CHONGCHONG_ADMIN_USERNAME,env.CHONGCHONG_ADMIN_PASSWORD,'platform')
const warehouses = requireSuccess(await context.request('GET','/api/platform/v1/last-leg/overseas-procurements/warehouse-options'))
if (!Array.isArray(warehouses) || !warehouses.some(row => String(row.warehouseId) === config.warehouseId)) throw new Error('指定收货仓不在有效采购仓候选中')
await prepareCustomer(context,{publishEnvironment:false})
if (context.steps.some(step => !step.passed)) throw new Error('独立客户准备检查未全部通过')
const fixture = context.context.merchantQualificationFixture
const customerId = String(fixture.tenantId), customerToken = await login(fixture.username,fixture.password,'customer')
const readWallet = async () => {
const page = requireSuccess(await context.request('GET',`/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${customerId}`))
const account = page.records?.find(row => String(row.customerId) === customerId)
if (!account) throw new Error('本轮独立客户钱包尚未初始化')
return account
}
const before = await readWallet()
requireSuccess(await context.request('POST',`/api/platform/finance/v1/accounts/${customerId}/adjustments`,{ type:'RECHARGE', amount:'10.00', occurredDate:new Date().toISOString().slice(0,10), paymentMethod:'BANK_TRANSFER', referenceNo:context.marker, remark:context.marker, expectedAccountVersion:before.accountVersion },context.token,{'Idempotency-Key':`${context.marker}-fund`}))
const upload = async (orderNo,category) => requireSuccess(await context.requestMultipart('POST',`/api/platform/v1/last-leg/overseas-procurements/${encodeURIComponent(orderNo)}/files/${category}?customerId=${customerId}`,{ content:PNG,type:'image/png',name:`${category.toLowerCase()}.png` }))
report.status = 'RUNNING'; await checkpoint()
await verifyProcurementSample(context,{ marker:context.marker,customerId,customerName:fixture.tenantName,customerToken,warehouseId:config.warehouseId,readWallet,upload })
report.status = 'PASSED_NORMAL_FLOW'
} catch (error) { failed = true; report.status = 'FAILED'; report.failure = safe(error.message) }
finally {
for (const session of sessions.reverse()) {
const state = report.sessions.find(item => item.actor === session.actor)
try {
const response = await rawRequest('POST','/api/auth/logout',{refreshToken:session.refreshToken},session.accessToken)
state.logout = response?.http === 200 && String(response?.body?.code) === '200' ? 'SUCCESS' : 'FAILED'
state.http = response?.http; state.code = response?.body?.code
if (state.logout !== 'SUCCESS') { failed = true; report.status = 'FAILED' }
} catch { state.logout = 'UNKNOWN_RESULT'; failed = true; report.status = 'FAILED' }
}
report.finishedAt = new Date().toISOString(); await checkpoint()
}
return { output,status:report.status,failed }
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
execute().then(result => { console.log(JSON.stringify(result)); process.exitCode = result.failed ? 1 : 0 })
.catch(() => { console.error('采购验收未启动或报告保存失败;请核对TEST配置、唯一批次和既有证据。'); process.exitCode = 1 })
}
@@ -0,0 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { execute, options } from './procurement-sample-test-env.mjs'
test('default preview needs no credentials and performs no execution', async () => {
const result = await execute([], [])
assert.equal(result.mode, 'PLAN'); assert.equal(result.sql, false); assert.equal(result.deploy, false)
})
test('execution requires exact TEST, unique bounded run id and receiving warehouse', () => {
for (const args of [ ['--execute'], ['--execute','--environment','prod','--run-id','r1','--warehouse-id','1'], ['--execute','--environment','TEST','--run-id','../r1','--warehouse-id','1'], ['--execute','--environment','TEST','--run-id','r1','--warehouse-id','0'] ]) assert.throws(() => options(args))
assert.deepEqual(options(['--execute','--environment','TEST','--run-id','r1','--warehouse-id','123']), { execute:true,runId:'r1',warehouseId:'123' })
})
test('execution rejects an absent or different environment file before login', async () => {
const args = ['--execute','--environment','TEST','--run-id','r1','--warehouse-id','123']
await assert.rejects(execute(args, []), /TEST 环境文件/)
await assert.rejects(execute(args, ['--env-file=C:\\private\\production.env']), /TEST 环境文件/)
})