Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # order/AGENTS.md
This commit is contained in:
@@ -64,6 +64,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
|
||||
|---|---|---|
|
||||
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 |
|
||||
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
|
||||
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
|
||||
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 |
|
||||
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
|
||||
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
|
||||
@@ -103,6 +104,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
|
||||
|---|---|---|---|
|
||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、海外排车/出库/派送/异常/回访、财务、通知、统计等模块 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
||||
| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、多产品、海外仓、多个私人地址、地址归属失败、渠道失败、重复行、逐行隔离、失败重试、详情归属、幂等重放与冲突;只创建草稿,不提交、不审核 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
||||
| 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` |
|
||||
@@ -111,6 +113,18 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套
|
||||
|
||||
推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。
|
||||
|
||||
## Client 快速下单定向验收
|
||||
|
||||
快速下单页面使用独立批量草稿接口,不等同于 Excel 批量导入。脚本按 Client 页面当前真实请求构造最小草稿,包含页面只要求 SKU 和箱数、其余货物字段为空值或零值的行为,用于及时发现前后端契约漂移。它会使用测试客户创建 FBA、海外仓和私人地址草稿,并验证无权限请求、单行失败隔离、详情客户归属与同幂等键重放。
|
||||
|
||||
```powershell
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/client-quick-order-test-env-acceptance.mjs
|
||||
```
|
||||
|
||||
需要额外随机创建多张草稿做压力回归时增加 `--random-count 20`。随机批次在三种目的仓类型间轮换,具体仓库/地址、每单产品数和箱数随机;逐张回查客户归属、草稿状态、目的类型和产品数量,并对整批执行幂等重放。取值范围 `1-50`,会在基础矩阵之外额外保留对应数量的草稿。
|
||||
|
||||
报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
|
||||
|
||||
## 固定客户待审核订单
|
||||
|
||||
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
|
||||
|
||||
@@ -172,7 +172,7 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
|
||||
|
||||
## 国内整柜与客户运营统计
|
||||
|
||||
- `domestic-container.mjs`:整柜入库、出库、库存列表和详情;静态契约为 `domestic-container-contract.test.mjs`。
|
||||
- `domestic-container.mjs`:整柜入库、出库、库存列表和详情,并覆盖字段边界、组合筛选与分页汇总、柜号标准化、同柜追加入库、货名冲突、完成出库强制库存版本、并发版本竞争和幂等重放;静态契约为 `domestic-container-contract.test.mjs`。
|
||||
- `customer-operating-statistics.mjs`:客户详情在途柜量/TEU 运营指标,复用 `platform:firstleg:container:list` 权限。
|
||||
- 两者由总入口 `admin-test-env-acceptance.mjs` 的默认模块列表编排;接口验收会写入测试数据,静态合同可离线执行。
|
||||
|
||||
|
||||
@@ -819,11 +819,9 @@ export async function run(context) {
|
||||
const skippedInboundId = id(partialTarget.inboundId)
|
||||
const partialBatchItems = (partialPda.task.items || []).map((item) => id(item.inboundId) === skippedInboundId
|
||||
? { itemId: id(item.id), action: 'SKIP', pickedPackageCount: null,
|
||||
finalPickedPackageCount: null, releaseMode: null,
|
||||
locationIds: [], version: Number(item.version), reason: '同柜不可拆组短拣验收' }
|
||||
finalPickedPackageCount: null, version: Number(item.version), reason: '同柜不可拆组短拣验收' }
|
||||
: { itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
|
||||
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
||||
version: Number(item.version), reason: null })
|
||||
finalPickedPackageCount: null, version: Number(item.version), reason: null })
|
||||
const partialBatchResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/batch`, {
|
||||
warehouseId: first.warehouseId, loadingPlanId: partialChain.planId,
|
||||
containerBookingId: partialChain.containerBookingId, expectedVersion: Number(partialPda.task.version),
|
||||
@@ -863,8 +861,7 @@ export async function run(context) {
|
||||
const fullTreesBefore = await fulfillmentTrees(context, fullChain.fixtures, '足量完成前')
|
||||
const fullBatchItems = (fullPda.task?.items || []).map((item) => ({
|
||||
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
|
||||
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
||||
version: Number(item.version), reason: null,
|
||||
finalPickedPackageCount: null, version: Number(item.version), reason: null,
|
||||
}))
|
||||
const fullBatchResponse = await context.request('POST', `${PDA}/tasks/${fullPda.taskId}/batch`, {
|
||||
warehouseId: third.warehouseId, loadingPlanId: fullChain.planId,
|
||||
@@ -910,8 +907,7 @@ export async function run(context) {
|
||||
const normalTreeBefore = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成前')
|
||||
const normalBatchItems = (normalPda.task?.items || []).map((item) => ({
|
||||
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
|
||||
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
||||
version: Number(item.version), reason: null,
|
||||
finalPickedPackageCount: null, version: Number(item.version), reason: null,
|
||||
}))
|
||||
const normalBatchResponse = await context.request('POST', `${PDA}/tasks/${normalPda.taskId}/batch`, {
|
||||
warehouseId: seventh.warehouseId, loadingPlanId: normalChain.planId,
|
||||
|
||||
@@ -28,8 +28,9 @@ test('覆盖国内仓、待单、完成入库、部分和全部出库及列表
|
||||
|
||||
test('在创建测试数据前覆盖无筛选整柜入库列表', () => {
|
||||
const unfilteredList = '`${BASE}/inbounds?page=1&size=20`'
|
||||
const runSource = moduleSource.slice(moduleSource.indexOf('export async function run(context)'))
|
||||
assert.match(moduleSource, /无筛选查询整柜入库单列表/)
|
||||
assert.ok(moduleSource.indexOf(unfilteredList) < moduleSource.indexOf("api(context, 'POST', `${BASE}/inbounds`"))
|
||||
assert.ok(runSource.indexOf(unfilteredList) < runSource.indexOf("api(context, 'POST', `${BASE}/inbounds`"))
|
||||
})
|
||||
|
||||
test('覆盖库存不足、版本冲突及两类幂等行为', () => {
|
||||
@@ -46,3 +47,20 @@ test('覆盖权限拒绝与 requestId traceId', () => {
|
||||
assert.match(moduleSource, /Boolean\(response\?\.requestId\).*Boolean\(response\?\.traceId\)/s)
|
||||
assert.match(moduleSource, /status: 'DISABLED'/)
|
||||
})
|
||||
|
||||
test('扩展覆盖字段边界、组合筛选、标准化、分页汇总和详情不存在', () => {
|
||||
for (const token of ['集装箱号格式非法被拒绝', '零箱入库被拒绝', '零体积入库被拒绝',
|
||||
'非法入库状态被拒绝', '空幂等键被拒绝', '超长备注被拒绝', '非法入库分页大小被拒绝',
|
||||
'非法库存状态筛选被拒绝', '同仓同柜同货名追加入库', '同仓同柜不同货名禁止合并库存',
|
||||
'筛选汇总不受分页大小影响且柜号已标准化', '入库单筛选总数与分页记录正确',
|
||||
'不存在的整柜库存详情返回未找到']) assert.match(moduleSource, new RegExp(token))
|
||||
})
|
||||
|
||||
test('扩展覆盖强制库存版本、并发竞争和成功请求幂等重放', () => {
|
||||
assert.match(moduleSource, /完成出库必须携带库存版本/)
|
||||
assert.match(moduleSource, /缺失库存版本的请求不得扣减库存/)
|
||||
assert.match(moduleSource, /Promise\.all\(raceBodies\.map/)
|
||||
assert.match(moduleSource, /同一库存版本并发出库仅一个成功/)
|
||||
assert.match(moduleSource, /并发出库只扣减一次并只新增一条流水/)
|
||||
assert.match(moduleSource, /并发成功请求重放不重复扣减/)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,102 @@ async function api(context, method, path, body, token = context.token) {
|
||||
return response
|
||||
}
|
||||
|
||||
async function verifyValidationBoundaries(context, warehouseId, marker, now) {
|
||||
const valid = {
|
||||
warehouseId, cargoName: marker, containerNo: `DCV${marker}`.slice(0, 32).toUpperCase(), boxCount: 1,
|
||||
volumeCbm: 1, inboundTime: now, status: 'COMPLETED', remark: marker,
|
||||
}
|
||||
const cases = [
|
||||
['集装箱号格式非法被拒绝', { ...valid, containerNo: 'bad!', idempotencyKey: `${marker}-invalid-container` }],
|
||||
['零箱入库被拒绝', { ...valid, boxCount: 0, idempotencyKey: `${marker}-zero-boxes` }],
|
||||
['零体积入库被拒绝', { ...valid, volumeCbm: 0, idempotencyKey: `${marker}-zero-volume` }],
|
||||
['非法入库状态被拒绝', { ...valid, status: 'UNKNOWN', idempotencyKey: `${marker}-invalid-status` }],
|
||||
['空幂等键被拒绝', { ...valid, idempotencyKey: ' ' }],
|
||||
['超长备注被拒绝', { ...valid, remark: 'R'.repeat(501), idempotencyKey: `${marker}-long-remark` }],
|
||||
]
|
||||
for (const [label, body] of cases) {
|
||||
const response = await api(context, 'POST', `${BASE}/inbounds`, body)
|
||||
expectCode(context, label, response, 'A0422')
|
||||
}
|
||||
expectCode(context, '非法入库分页大小被拒绝',
|
||||
await api(context, 'GET', `${BASE}/inbounds?page=1&size=201`), 'A0422')
|
||||
expectCode(context, '非法库存状态筛选被拒绝',
|
||||
await api(context, 'GET', `${BASE}/inventories?page=1&size=20&status=UNKNOWN`), 'A0422')
|
||||
}
|
||||
|
||||
async function verifyExtendedInventoryScenarios(context, warehouseId, marker, now) {
|
||||
const containerNo = `DCX${marker}`.slice(0, 32).toUpperCase()
|
||||
const cargoName = `${marker}-extended`.slice(0, 120)
|
||||
const firstBody = { warehouseId, cargoName, containerNo, boxCount: 40, volumeCbm: 20,
|
||||
inboundTime: now, status: 'COMPLETED', remark: `${marker}-extended-1`, idempotencyKey: `${marker}-extended-1` }
|
||||
context.apiSuccess(MODULE, '扩展场景首次完成入库', await api(context, 'POST', `${BASE}/inbounds`, firstBody))
|
||||
context.apiSuccess(MODULE, '同仓同柜同货名追加入库', await api(context, 'POST', `${BASE}/inbounds`, {
|
||||
...firstBody, cargoName: ` ${cargoName} `, containerNo: containerNo.toLowerCase(), boxCount: 10, volumeCbm: 5,
|
||||
remark: `${marker}-extended-2`, idempotencyKey: `${marker}-extended-2`,
|
||||
}))
|
||||
expectCode(context, '同仓同柜不同货名禁止合并库存', await api(context, 'POST', `${BASE}/inbounds`, {
|
||||
...firstBody, cargoName: `${cargoName}-other`, boxCount: 1, volumeCbm: 1,
|
||||
idempotencyKey: `${marker}-cargo-conflict`,
|
||||
}), 'A0409')
|
||||
|
||||
const filteredResponse = await api(context, 'GET', `${BASE}/inventories?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo.toLowerCase()}&cargoName=${encodeURIComponent(cargoName)}&status=IN_STOCK`)
|
||||
const filtered = context.apiSuccess(MODULE, '组合条件筛选扩展库存', filteredResponse)
|
||||
const inventory = filtered?.records?.[0]
|
||||
context.assert(MODULE, '筛选汇总不受分页大小影响且柜号已标准化', filteredResponse,
|
||||
Number(filtered?.total) === 1 && filtered?.records?.length === 1 && inventory?.containerNo === containerNo
|
||||
&& Number(filtered?.summary?.inboundBoxes) === 50 && Number(filtered?.summary?.availableBoxes) === 50
|
||||
&& Number(filtered?.summary?.inboundVolumeCbm) === 25,
|
||||
`total=${filtered?.total}, records=${filtered?.records?.length}, containerNo=${inventory?.containerNo}, summary=${JSON.stringify(filtered?.summary)}`)
|
||||
if (!inventory?.id) throw new Error('扩展库存未创建成功')
|
||||
|
||||
const inboundListResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo}&cargoName=${encodeURIComponent(cargoName)}&status=COMPLETED`)
|
||||
const inboundList = context.apiSuccess(MODULE, '组合条件筛选扩展入库单', inboundListResponse)
|
||||
context.assert(MODULE, '入库单筛选总数与分页记录正确', inboundListResponse,
|
||||
Number(inboundList?.total) === 2 && inboundList?.records?.length === 1
|
||||
&& inboundList.records[0]?.status === 'COMPLETED' && inboundList.records[0]?.businessType === 'INBOUND',
|
||||
`total=${inboundList?.total}, records=${inboundList?.records?.length}, status=${inboundList?.records?.[0]?.status}`)
|
||||
|
||||
const beforeMissingVersion = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`)
|
||||
const beforeDetail = context.apiSuccess(MODULE, '缺失版本测试前读取库存详情', beforeMissingVersion)
|
||||
expectCode(context, '完成出库必须携带库存版本', await api(context, 'POST', `${BASE}/outbounds`, {
|
||||
warehouseId, containerNo, boxCount: 1, outboundTime: now, status: 'COMPLETED',
|
||||
idempotencyKey: `${marker}-missing-version`,
|
||||
}), 'A0422')
|
||||
const afterMissingVersion = context.apiSuccess(MODULE, '缺失版本请求后回查库存',
|
||||
await api(context, 'GET', `${BASE}/inventories/${inventory.id}`))
|
||||
context.assert(MODULE, '缺失库存版本的请求不得扣减库存', beforeMissingVersion,
|
||||
Number(afterMissingVersion?.inventory?.availableBoxes) === Number(beforeDetail?.inventory?.availableBoxes),
|
||||
`before=${beforeDetail?.inventory?.availableBoxes}, after=${afterMissingVersion?.inventory?.availableBoxes}`)
|
||||
|
||||
const currentVersion = Number(afterMissingVersion?.inventory?.version)
|
||||
const availableBeforeRace = Number(afterMissingVersion?.inventory?.availableBoxes)
|
||||
const raceBodies = [1, 2].map((sequence) => ({ warehouseId, containerNo, boxCount: 7, outboundTime: now,
|
||||
status: 'COMPLETED', inventoryVersion: currentVersion, idempotencyKey: `${marker}-race-${sequence}` }))
|
||||
const raceResponses = await Promise.all(raceBodies.map((body) => context.request('POST', `${BASE}/outbounds`, body, context.token)))
|
||||
raceResponses.forEach((response, index) => traced(context, `并发出库请求${index + 1}`, response))
|
||||
const winners = raceResponses.filter(succeeded)
|
||||
const conflicts = raceResponses.filter((response) => response?.http === 200 && String(response?.body?.code) === 'A0409')
|
||||
context.assert(MODULE, '同一库存版本并发出库仅一个成功', raceResponses[0], winners.length === 1 && conflicts.length === 1,
|
||||
`success=${winners.length}, conflict=${conflicts.length}`)
|
||||
const afterRaceResponse = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`)
|
||||
const afterRace = context.apiSuccess(MODULE, '并发出库后回查库存', afterRaceResponse)
|
||||
context.assert(MODULE, '并发出库只扣减一次并只新增一条流水', afterRaceResponse,
|
||||
Number(afterRace?.inventory?.availableBoxes) === availableBeforeRace - 7
|
||||
&& afterRace?.transactions?.length === 3 && Number(afterRace?.outboundOrderCount) === 1,
|
||||
`before=${availableBeforeRace}, after=${afterRace?.inventory?.availableBoxes}, transactions=${afterRace?.transactions?.length}, outboundOrders=${afterRace?.outboundOrderCount}`)
|
||||
const winnerIndex = raceResponses.findIndex(succeeded)
|
||||
if (winnerIndex >= 0) {
|
||||
const firstWinner = raceResponses[winnerIndex]?.body?.data
|
||||
const replay = await api(context, 'POST', `${BASE}/outbounds`, raceBodies[winnerIndex])
|
||||
const replayData = context.apiSuccess(MODULE, '并发成功请求使用原幂等键重放', replay)
|
||||
context.assert(MODULE, '并发成功请求重放不重复扣减', replay,
|
||||
replayData?.replayed === true && id(replayData?.id) === id(firstWinner?.id),
|
||||
`replayed=${replayData?.replayed}, firstId=${id(firstWinner?.id)}, replayId=${id(replayData?.id)}`)
|
||||
}
|
||||
expectCode(context, '不存在的整柜库存详情返回未找到',
|
||||
await api(context, 'GET', `${BASE}/inventories/1`), 'A0404')
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const marker = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
||||
const initialInboundsResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=20`)
|
||||
@@ -37,6 +133,8 @@ export async function run(context) {
|
||||
const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase()
|
||||
const now = new Date().toISOString().slice(0, 19)
|
||||
|
||||
await verifyValidationBoundaries(context, warehouseId, marker, now)
|
||||
|
||||
const invalidWarehouse = await api(context, 'POST', `${BASE}/inbounds`, {
|
||||
warehouseId: '1', cargoName: marker, containerNo: `${containerNo}X`, boxCount: 1, volumeCbm: 1,
|
||||
inboundTime: now, status: 'COMPLETED', remark: marker, idempotencyKey: `${marker}-invalid-warehouse`,
|
||||
@@ -122,6 +220,8 @@ export async function run(context) {
|
||||
inboundRows?.records?.length === 2 && outboundRows?.records?.length === 3,
|
||||
`inbounds=${inboundRows?.records?.length}, outbounds=${outboundRows?.records?.length}`)
|
||||
|
||||
await verifyExtendedInventoryScenarios(context, warehouseId, marker, now)
|
||||
|
||||
await verifyPermissionDenial(context, marker, warehouseId, containerNo)
|
||||
context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS }
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ async function pickAll(context, chain, label) {
|
||||
expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken,
|
||||
items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK',
|
||||
pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null,
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null })),
|
||||
version: Number(item.version), reason: null })),
|
||||
}, `${label}-batch`)
|
||||
chain.task = context.apiSuccess(MODULE, `${label} 完成 PDA 拣货`, response, '', OPTIONS)
|
||||
return chain
|
||||
|
||||
@@ -240,9 +240,22 @@ export async function run(context) {
|
||||
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse,
|
||||
foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)),
|
||||
const foreignAssigned = foreignAssignResponse.body?.data
|
||||
const foreignItem = Array.isArray(foreignAssigned?.items)
|
||||
? foreignAssigned.items.find((item) => id(item.inboundId) === inboundId
|
||||
&& id(item.inboundDetailId) === id(candidate.inboundDetailId))
|
||||
: null
|
||||
context.addStep(MODULE, '手工装柜允许两个真实有效渠道之间跨渠道分配', foreignAssignResponse,
|
||||
foreignAssignResponse.http === 200 && codeOf(foreignAssignResponse) === '200' && Boolean(foreignItem?.id),
|
||||
`primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
|
||||
if (foreignItem?.id) {
|
||||
const foreignRemoveResponse = await request(context, 'DELETE',
|
||||
`${SMART}/plans/${foreignPlan?.id}/items/${foreignItem.id}`, {
|
||||
expectedPlanVersion: Number(foreignAssigned.version), expectedItemVersion: Number(foreignItem.version),
|
||||
operationId: `${context.marker}-other-channel-remove`.slice(0, 128),
|
||||
})
|
||||
context.apiSuccess(MODULE, '跨渠道分配验收后释放入仓码供主流程继续', foreignRemoveResponse, '', OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
||||
@@ -678,8 +691,7 @@ export async function run(context) {
|
||||
|
||||
const batchPath = `${PDA}/tasks/${taskId}/batch`
|
||||
const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1,
|
||||
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
|
||||
version: Number(pickItem.version), reason: null }]
|
||||
finalPickedPackageCount: null, version: Number(pickItem.version), reason: null }]
|
||||
const staleBatchResponse = await request(context, 'POST', batchPath, {
|
||||
warehouseId, loadingPlanId: planId, containerBookingId: containerId,
|
||||
expectedVersion: Number(task.version) + 1,
|
||||
|
||||
@@ -655,7 +655,7 @@ export async function run(context) {
|
||||
inventorySnapshotToken: inventory.snapshotToken,
|
||||
items: [{
|
||||
itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1,
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version),
|
||||
version: Number(canonicalItem.version),
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -1277,7 +1277,7 @@ export async function run(context) {
|
||||
expectedVersion: Number(afterUnresolvedCompleteTask?.version),
|
||||
inventorySnapshotToken: partialInventory?.snapshotToken,
|
||||
items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1,
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }],
|
||||
version: Number(unresolvedTaskItem?.version) }],
|
||||
}, { 'Idempotency-Key': partialBatchKey })
|
||||
const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS)
|
||||
const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id))
|
||||
@@ -1347,7 +1347,7 @@ export async function run(context) {
|
||||
expectedVersion: Number(incrementedTask?.version),
|
||||
inventorySnapshotToken: effectiveBatchPayload.inventorySnapshotToken,
|
||||
items: [{ itemId: id(incrementalTaskItem.id), action: 'PICK', pickedPackageCount: 1,
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(incrementalTaskItem.version) }],
|
||||
version: Number(incrementalTaskItem.version) }],
|
||||
}, { 'Idempotency-Key': `${context.marker}-dynamic-stale-inventory`.slice(0, 128) })
|
||||
context.addStep(MODULE, '增量后旧 inventory token 明确 A0409 并要求刷新', staleIncrementBatch,
|
||||
staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409'
|
||||
@@ -1424,7 +1424,7 @@ export async function run(context) {
|
||||
expectedVersion: Number(refreshedDynamicTask?.version),
|
||||
inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken,
|
||||
items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount),
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }],
|
||||
version: Number(refreshedDynamicTaskItem?.version) }],
|
||||
}, { 'Idempotency-Key': dynamicBatchKey })
|
||||
const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse,
|
||||
|
||||
@@ -75,7 +75,7 @@ test('each remainder settles only through inventory snapshot, atomic batch and e
|
||||
assert.match(source, /expectedVersion: Number\(inventory\.taskVersion\)/)
|
||||
assert.match(source, /inventorySnapshotToken: inventory\.snapshotToken/)
|
||||
assert.match(source, /action: 'PICK', pickedPackageCount: 1/)
|
||||
assert.match(source, /version: Number\(claimedItem\.version\), releaseMode: 'MANUAL', locationIds: \[\]/)
|
||||
assert.doesNotMatch(source, /releaseMode|locationIds|reservationIds/)
|
||||
assert.match(source, /'Idempotency-Key': batchKey/)
|
||||
assert.match(source, /differentReplay/)
|
||||
assert.match(source, /workflowStatus === 'AWAITING_REVIEW'/)
|
||||
|
||||
@@ -392,7 +392,7 @@ async function loadOneFragment(context, state) {
|
||||
expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken,
|
||||
items: [{
|
||||
itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1,
|
||||
version: Number(claimedItem.version), releaseMode: 'MANUAL', locationIds: [],
|
||||
version: Number(claimedItem.version),
|
||||
}],
|
||||
}
|
||||
const staleBatch = await request(context, 'POST', batchPath, {
|
||||
|
||||
@@ -371,12 +371,23 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch
|
||||
assert.match(value, /codeOf\(foreignAssignResponse\) === 'A0422'/)
|
||||
})
|
||||
|
||||
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
|
||||
test('manual cross-channel loading fixture releases the inbound before the primary flow', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assertOrder(value, [
|
||||
'新增真实异渠道模拟柜',
|
||||
'手工装柜允许两个真实有效渠道之间跨渠道分配',
|
||||
'跨渠道分配验收后释放入仓码供主流程继续',
|
||||
'人工分配本轮候选货物',
|
||||
])
|
||||
})
|
||||
|
||||
test('container loading permits manual cross-channel allocation and keeps stable PDA identity across container number changes', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assertOrder(value, [
|
||||
'新增内部模拟柜',
|
||||
'新增真实异渠道模拟柜',
|
||||
'两个真实有效渠道之间拒绝跨渠道装柜',
|
||||
'手工装柜允许两个真实有效渠道之间跨渠道分配',
|
||||
'跨渠道分配验收后释放入仓码供主流程继续',
|
||||
'人工分配本轮候选货物',
|
||||
'分配同步建立 bridge',
|
||||
'无真实柜号时 PDA 查询 A0404',
|
||||
|
||||
@@ -177,8 +177,8 @@ Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口'
|
||||
Assert-Contains $firstlegAgents '客户四档箱规与默认算法' 'Firstleg 客户箱规入口'
|
||||
Assert-Contains $firstlegAgents '`ADD/MULTIPLY`' 'Firstleg 箱规算法约束'
|
||||
Assert-Contains $adminAgents '客户箱规策略与计价主数据' 'Admin 箱规计费入口'
|
||||
Assert-Contains $backendAgents '全局智能装柜候选、柜次与箱数分配' '后端全局智能装柜路由'
|
||||
Assert-Contains $firstlegAgents '同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界'
|
||||
Assert-Contains $backendAgents '全局智能装柜候选、柜次、占位与移除' '后端全局智能装柜路由'
|
||||
Assert-Contains $firstlegAgents '必须成员完整、满箱并作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界'
|
||||
Assert-Contains $adminAgents 'GlobalSmartLoadingView.vue' 'Admin 全局智能装柜入口'
|
||||
Assert-Contains $financeAgents 'StatementLine.inboundCode' 'Finance 账单入仓码字段'
|
||||
Assert-Contains $clientAgents '账单行只显示服务端 `inboundCode`' 'Client 账单入仓码联查边界'
|
||||
@@ -215,6 +215,36 @@ Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBM
|
||||
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
|
||||
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先'
|
||||
|
||||
# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。
|
||||
$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java')
|
||||
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
|
||||
$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts')
|
||||
$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts')
|
||||
Assert-Contains $rootAgents '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
|
||||
Assert-Contains $rootAgents '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
|
||||
Assert-Contains $rootAgents '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
|
||||
Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口'
|
||||
Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口'
|
||||
Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界'
|
||||
Assert-Contains $orderAgents 'PendingReviewOrderQueryService.java' 'Order 待审核装柜候选入口'
|
||||
Assert-Contains $adminAgents '按钮资格以服务端 `canAssign` 为准' 'Admin 智能装柜资格边界'
|
||||
Assert-Contains $firstlegOpenApi '未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、' '智能装柜四态 OpenAPI'
|
||||
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:' '待审核占位 OpenAPI'
|
||||
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI'
|
||||
Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询'
|
||||
Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射'
|
||||
Assert-Contains $rootAgents '客户入库结果与头程费用确认' '根客户入库费用确认入口'
|
||||
Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认'
|
||||
Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口'
|
||||
Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI'
|
||||
Assert-Contains $merchantInboundBillingService 'pricing.confirmByCustomer' 'Firstleg 客户费用确认服务'
|
||||
Assert-Contains $clientAgents '“记住账号”只记登录标识' 'Client 记住账号边界'
|
||||
Assert-Contains $clientSession 'localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)' 'Client 刷新令牌持久化'
|
||||
Assert-Contains $clientHttp "'/auth/refresh'" 'Client 公开刷新入口'
|
||||
Assert-Path 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java'
|
||||
Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java'
|
||||
Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPanel.vue'
|
||||
|
||||
# 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。
|
||||
$authAgents = Read-RequiredText (Join-Path $backendRoot 'auth/AGENTS.md')
|
||||
$iamAgents = Read-RequiredText (Join-Path $backendRoot 'iam/AGENTS.md')
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = await readFile(new URL('./client-quick-order-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('quick-order HTTP acceptance uses the same endpoint and skeletal cargo payload as Client', () => {
|
||||
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/batch/)
|
||||
assert.match(source, /cargoes: Array\.from\(\{ length: cargoCount \}/)
|
||||
assert.match(source, /cargoName: '', packageType: 'CARTON', packageCount: index \+ 1, grossWeightKg: 0, volumeCbm: 0/)
|
||||
assert.match(source, /destinationType: 'FBA'/)
|
||||
assert.match(source, /destinationType: 'OVERSEAS'/)
|
||||
assert.match(source, /destinationType: 'PRIVATE'/)
|
||||
assert.match(source, /addresses\?addressType=PRIVATE_DESTINATION/)
|
||||
assert.match(source, /privateDestinationAddressId: String\(address\.id\)/)
|
||||
})
|
||||
|
||||
test('quick-order HTTP acceptance verifies expanded row, cargo, isolation, retry, and idempotency behavior', () => {
|
||||
assert.match(source, /expected succeeded=5/)
|
||||
assert.match(source, /cargo count mismatch/)
|
||||
assert.match(source, /unowned private address must fail closed with A0404/)
|
||||
assert.match(source, /duplicate clientRowId must fail independently with A0409/)
|
||||
assert.match(source, /draft must belong to the authenticated enterprise/)
|
||||
assert.match(source, /same idempotency key replays the same quick-order drafts/)
|
||||
assert.match(source, /idempotent replay returned different order IDs/)
|
||||
assert.match(source, /same row id and idempotency key reject a changed payload/)
|
||||
assert.match(source, /a corrected failed row succeeds under a fresh batch key/)
|
||||
assert.match(source, /--random-count <1-50>/)
|
||||
assert.match(source, /create \$\{randomCount\} randomized quick-order drafts/)
|
||||
assert.match(source, /verify all \$\{randomCount\} randomized drafts/)
|
||||
assert.match(source, /replay all \$\{randomCount\} randomized drafts idempotently/)
|
||||
assert.match(source, /randomInt\(1, 6\)/)
|
||||
})
|
||||
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomInt, randomUUID } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help) {
|
||||
console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir <path>] [--timeout-ms <ms>] [--random-count <1-50>]')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY)
|
||||
const username = process.env.MERCHANT_TEST_USERNAME
|
||||
const password = process.env.MERCHANT_TEST_PASSWORD
|
||||
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
||||
const randomCount = Number(args['random-count'] ?? 0)
|
||||
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
|
||||
const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}`
|
||||
const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`)
|
||||
const report = {
|
||||
marker,
|
||||
generatedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
success: false,
|
||||
fatal: null,
|
||||
resources: { batchKey: null, randomBatchKey: null, orderNos: [] },
|
||||
steps: [],
|
||||
}
|
||||
|
||||
if (!gateway || !username || !password) {
|
||||
console.error('ADMIN_TEST_GATEWAY, MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD are required')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
||||
console.error('--timeout-ms must be at least 1000')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!Number.isInteger(randomCount) || randomCount < 0 || randomCount > 50) {
|
||||
console.error('--random-count must be an integer between 1 and 50')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let token
|
||||
try {
|
||||
await step('unauthenticated batch request is rejected', async () => {
|
||||
const response = await rawRequest('/api/merchant/v1/orders/first-leg/batch', {
|
||||
method: 'POST', body: [], headers: { 'Idempotency-Key': `quick-order-anonymous-${marker}` },
|
||||
})
|
||||
assert([401, 403].includes(response.status), `expected HTTP 401/403, got ${response.status}`)
|
||||
return { httpStatus: response.status }
|
||||
})
|
||||
|
||||
const login = await step('merchant login', async () => {
|
||||
const response = await jsonRequest('/api/auth/login', {
|
||||
method: 'POST', body: { loginId: username, password }, authenticated: false,
|
||||
})
|
||||
requireSuccess(response)
|
||||
assert(response.data?.accessToken, 'accessToken is required')
|
||||
token = response.data.accessToken
|
||||
return { authenticated: true }
|
||||
})
|
||||
assert(login.authenticated, 'login did not complete')
|
||||
|
||||
const profile = await step('load authoritative enterprise profile', async () => {
|
||||
const response = await jsonRequest('/api/merchant/v1/iam/tenant/profile')
|
||||
requireSuccess(response)
|
||||
assert(response.data?.tenantName?.trim(), 'tenantName is required')
|
||||
return { tenantId: String(response.data.id), tenantName: response.data.tenantName.trim() }
|
||||
})
|
||||
const policy = await step('load inbound-code policy', async () => {
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/inbound-code-policy')
|
||||
requireSuccess(response)
|
||||
assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`)
|
||||
return response.data
|
||||
})
|
||||
let privateAddresses = []
|
||||
await step('load customer-owned private destination addresses', async () => {
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION')
|
||||
requireSuccess(response)
|
||||
privateAddresses = arrayData(response.data)
|
||||
assert(privateAddresses.length > 0, 'at least one customer-owned private destination address is required')
|
||||
return { count: privateAddresses.length }
|
||||
})
|
||||
const domestic = await loadWarehouses('DOMESTIC')
|
||||
const fba = await loadWarehouses('FBA')
|
||||
const overseas = await loadWarehouses('OVERSEAS')
|
||||
assert(domestic.length > 0, 'at least one ACTIVE domestic warehouse is required')
|
||||
assert(fba.length > 0, 'at least one ACTIVE FBA warehouse is required')
|
||||
assert(overseas.length > 0, 'at least one ACTIVE overseas warehouse is required')
|
||||
|
||||
let receiving
|
||||
let channel
|
||||
for (const warehouse of domestic) {
|
||||
const response = await jsonRequest(`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`)
|
||||
requireSuccess(response)
|
||||
const candidate = arrayData(response.data).find((item) => arrayData(item.pricingTaxModes).length > 0)
|
||||
if (candidate) { receiving = warehouse; channel = candidate; break }
|
||||
}
|
||||
assert(receiving && channel, 'no domestic warehouse has an active pricing channel')
|
||||
|
||||
const fbaDestination = (warehouse) => ({
|
||||
destinationType: 'FBA', destinationCountryCode: warehouse.countryCode, destinationWarehouseId: warehouse.id,
|
||||
destinationWarehouseName: warehouse.warehouseName, amazonWarehouseCode: warehouse.warehouseCode,
|
||||
})
|
||||
const overseasDestination = (warehouse) => ({
|
||||
destinationType: 'OVERSEAS', destinationCountryCode: warehouse.countryCode,
|
||||
destinationWarehouseId: warehouse.id, destinationWarehouseName: warehouse.warehouseName,
|
||||
})
|
||||
const privateDestination = (address) => ({
|
||||
destinationType: 'PRIVATE', destinationCountryCode: address.countryCode,
|
||||
destinationWarehouseName: address.addressName, privateDestinationAddressId: String(address.id),
|
||||
})
|
||||
const invalidChannelRowId = `quick-order-invalid-channel-${marker}`
|
||||
const invalidPrivateRowId = `quick-order-invalid-private-${marker}`
|
||||
const rows = [
|
||||
{ clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
||||
fbaDestination(fba[0]), 'FBA') },
|
||||
{ clientRowId: `quick-order-fba-multi-cargo-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
||||
fbaDestination(fba[1] ?? fba[0]), 'FBA-MULTI', 3) },
|
||||
{ clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
||||
overseasDestination(overseas[1] ?? overseas[0]), 'OVERSEAS') },
|
||||
{ clientRowId: `quick-order-private-a-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
||||
privateDestination(privateAddresses[0]), 'PRIVATE-A') },
|
||||
{ clientRowId: `quick-order-private-b-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
||||
privateDestination(privateAddresses[1] ?? privateAddresses[0]), 'PRIVATE-B') },
|
||||
{ clientRowId: invalidChannelRowId, draft: {
|
||||
...quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'INVALID'),
|
||||
channelCode: `INVALID-${marker}`, channelName: '不存在的渠道',
|
||||
} },
|
||||
{ clientRowId: invalidPrivateRowId, draft: quickDraft(profile.tenantName, policy, receiving, channel, {
|
||||
destinationType: 'PRIVATE', destinationCountryCode: 'US', destinationWarehouseName: '越权私人仓',
|
||||
privateDestinationAddressId: '9223372036854775807',
|
||||
}, 'INVALID-PRIVATE') },
|
||||
{ clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
||||
fbaDestination(fba[0]), 'DUPLICATE') },
|
||||
]
|
||||
const expectedCargoCounts = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.cargoes.length]))
|
||||
const expectedDestinationTypes = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.destinationType]))
|
||||
|
||||
const batchKey = `quick-order-http-${randomUUID()}`
|
||||
report.resources.batchKey = batchKey
|
||||
const created = await step('create five varied quick-order drafts while isolating three invalid rows', async () => {
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
||||
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
|
||||
})
|
||||
requireSuccess(response)
|
||||
assert(response.data?.total === 8, `expected total=8, got ${response.data?.total}`)
|
||||
const rowEvidence = arrayData(response.data?.items).map((item) => ({
|
||||
clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo,
|
||||
errorCode: item.errorCode, errorMessage: item.errorMessage,
|
||||
}))
|
||||
assert(response.data?.succeeded === 5,
|
||||
`expected succeeded=5, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`)
|
||||
assert(response.data?.failed === 3,
|
||||
`expected failed=3, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`)
|
||||
const invalidChannel = response.data.items.find((item) => item.clientRowId === invalidChannelRowId)
|
||||
const invalidPrivate = response.data.items.find((item) => item.clientRowId === invalidPrivateRowId)
|
||||
const duplicate = response.data.items[7]
|
||||
assert(invalidChannel && !invalidChannel.success && invalidChannel.errorCode === 'A0409',
|
||||
'invalid channel row must fail independently with A0409')
|
||||
assert(invalidPrivate && !invalidPrivate.success && invalidPrivate.errorCode === 'A0404',
|
||||
`unowned private address must fail closed with A0404; actual=${JSON.stringify(invalidPrivate)}`)
|
||||
assert(duplicate && !duplicate.success && duplicate.errorCode === 'A0409',
|
||||
'duplicate clientRowId must fail independently with A0409')
|
||||
return { ...response.data, items: rowEvidence }
|
||||
})
|
||||
|
||||
const successfulItems = created.items.filter((item) => item.success)
|
||||
for (const item of successfulItems) {
|
||||
await step(`verify created draft ${item.clientRowId}`, async () => {
|
||||
assert(item.orderId != null, 'successful row must include orderId')
|
||||
assert(item.orderNo, 'successful row must include orderNo')
|
||||
const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
|
||||
requireSuccess(response)
|
||||
assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`)
|
||||
assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise')
|
||||
assert(response.data?.order?.destinationType === expectedDestinationTypes.get(item.clientRowId),
|
||||
`destination type mismatch for ${item.clientRowId}`)
|
||||
assert(arrayData(response.data?.cargoes).length === expectedCargoCounts.get(item.clientRowId),
|
||||
`cargo count mismatch for ${item.clientRowId}`)
|
||||
report.resources.orderNos.push(item.orderNo)
|
||||
return { orderNo: item.orderNo, status: response.data.order.status,
|
||||
destinationType: response.data.order.destinationType, cargoCount: response.data.cargoes.length }
|
||||
})
|
||||
}
|
||||
|
||||
await step('same idempotency key replays the same quick-order drafts', async () => {
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
||||
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
|
||||
})
|
||||
requireSuccess(response)
|
||||
const originalIds = successfulItems.map((item) => String(item.orderId))
|
||||
const replayIds = response.data.items.filter((item) => item.success).map((item) => String(item.orderId))
|
||||
assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), 'idempotent replay returned different order IDs')
|
||||
return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) }
|
||||
})
|
||||
|
||||
await step('same row id and idempotency key reject a changed payload without overwriting the draft', async () => {
|
||||
const changed = structuredClone(rows[0])
|
||||
changed.draft.cargoes[0].packageCount = 99
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
||||
method: 'POST', body: [changed], headers: { 'Idempotency-Key': batchKey },
|
||||
})
|
||||
requireSuccess(response)
|
||||
assert(response.data?.succeeded === 0 && response.data?.failed === 1,
|
||||
'changed payload with the same idempotency key must fail')
|
||||
assert(response.data?.items?.[0]?.errorCode === 'A0409',
|
||||
`expected idempotency conflict A0409, got ${response.data?.items?.[0]?.errorCode}`)
|
||||
return { errorCode: response.data.items[0].errorCode, errorMessage: response.data.items[0].errorMessage }
|
||||
})
|
||||
|
||||
await step('a corrected failed row succeeds under a fresh batch key', async () => {
|
||||
const corrected = {
|
||||
clientRowId: invalidChannelRowId,
|
||||
draft: quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'RETRY'),
|
||||
}
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
||||
method: 'POST', body: [corrected], headers: { 'Idempotency-Key': `quick-order-retry-${randomUUID()}` },
|
||||
})
|
||||
requireSuccess(response)
|
||||
const item = response.data?.items?.[0]
|
||||
assert(response.data?.succeeded === 1 && item?.success && item?.orderId != null,
|
||||
`corrected row did not succeed: ${JSON.stringify(response.data)}`)
|
||||
const detail = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
|
||||
requireSuccess(detail)
|
||||
assert(detail.data?.order?.status === 'DRAFT', 'corrected row must remain a draft')
|
||||
assert(detail.data?.order?.customerName === profile.tenantName,
|
||||
'corrected draft must belong to the authenticated enterprise')
|
||||
report.resources.orderNos.push(item.orderNo)
|
||||
return { orderNo: item.orderNo, status: detail.data.order.status }
|
||||
})
|
||||
|
||||
if (randomCount > 0) {
|
||||
const destinationBuilders = [
|
||||
() => fbaDestination(fba[randomInt(fba.length)]),
|
||||
() => overseasDestination(overseas[randomInt(overseas.length)]),
|
||||
() => privateDestination(privateAddresses[randomInt(privateAddresses.length)]),
|
||||
]
|
||||
const randomRows = Array.from({ length: randomCount }, (_, index) => {
|
||||
const destination = destinationBuilders[index % destinationBuilders.length]()
|
||||
const cargoCount = randomInt(1, 6)
|
||||
const draft = quickDraft(profile.tenantName, policy, receiving, channel, destination,
|
||||
`RANDOM-${index + 1}`, cargoCount)
|
||||
draft.cargoes.forEach((cargo) => {
|
||||
cargo.packageCount = randomInt(1, 31)
|
||||
cargo.totalQuantity = cargo.packageCount * randomInt(1, 11)
|
||||
cargo.unitsPerPackage = Math.max(1, Math.floor(cargo.totalQuantity / cargo.packageCount))
|
||||
})
|
||||
return { clientRowId: `quick-order-random-${index + 1}-${marker}`, draft }
|
||||
})
|
||||
const randomBatchKey = `quick-order-random-http-${randomUUID()}`
|
||||
report.resources.randomBatchKey = randomBatchKey
|
||||
const randomCreated = await step(`create ${randomCount} randomized quick-order drafts`, async () => {
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
||||
method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey },
|
||||
})
|
||||
requireSuccess(response)
|
||||
assert(response.data?.total === randomCount, `expected random total=${randomCount}, got ${response.data?.total}`)
|
||||
assert(response.data?.succeeded === randomCount && response.data?.failed === 0,
|
||||
`random batch failures: ${JSON.stringify(response.data?.items?.filter((item) => !item.success))}`)
|
||||
return response.data.items.map((item, index) => ({
|
||||
clientRowId: item.clientRowId, orderId: item.orderId, orderNo: item.orderNo,
|
||||
destinationType: randomRows[index].draft.destinationType,
|
||||
cargoCount: randomRows[index].draft.cargoes.length,
|
||||
}))
|
||||
})
|
||||
|
||||
await step(`verify all ${randomCount} randomized drafts`, async () => {
|
||||
const evidence = []
|
||||
for (const item of randomCreated) {
|
||||
const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
|
||||
requireSuccess(response)
|
||||
assert(response.data?.order?.status === 'DRAFT', `random draft ${item.orderNo} is not DRAFT`)
|
||||
assert(response.data?.order?.customerName === profile.tenantName,
|
||||
`random draft ${item.orderNo} is outside the authenticated enterprise`)
|
||||
assert(response.data?.order?.destinationType === item.destinationType,
|
||||
`random draft ${item.orderNo} destination type mismatch`)
|
||||
assert(arrayData(response.data?.cargoes).length === item.cargoCount,
|
||||
`random draft ${item.orderNo} cargo count mismatch`)
|
||||
report.resources.orderNos.push(item.orderNo)
|
||||
evidence.push({ orderNo: item.orderNo, destinationType: item.destinationType, cargoCount: item.cargoCount })
|
||||
}
|
||||
return evidence
|
||||
})
|
||||
|
||||
await step(`replay all ${randomCount} randomized drafts idempotently`, async () => {
|
||||
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
||||
method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey },
|
||||
})
|
||||
requireSuccess(response)
|
||||
const originalIds = randomCreated.map((item) => String(item.orderId))
|
||||
const replayIds = response.data?.items?.map((item) => String(item.orderId))
|
||||
assert(JSON.stringify(replayIds) === JSON.stringify(originalIds),
|
||||
'randomized idempotent replay returned different order IDs')
|
||||
return { replayed: replayIds.length }
|
||||
})
|
||||
}
|
||||
|
||||
report.success = report.steps.every((item) => item.passed)
|
||||
} catch (error) {
|
||||
report.fatal = sanitizeError(error)
|
||||
report.success = false
|
||||
} finally {
|
||||
report.completedAt = new Date().toISOString()
|
||||
await mkdir(reportDir, { recursive: true })
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
console.log(JSON.stringify({ success: report.success, reportPath, orderNos: report.resources.orderNos, fatal: report.fatal }))
|
||||
}
|
||||
|
||||
process.exitCode = report.success ? 0 : 1
|
||||
|
||||
function quickDraft(customerName, policy, receiving, channel, destination, suffix, cargoCount = 1) {
|
||||
const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined
|
||||
return {
|
||||
customerName,
|
||||
transportMode: 'SEA', tradeTerm: 'FOB', expectedShipDate: undefined, customerRemark: `快速下单 HTTP 验收 ${marker} ${suffix}`,
|
||||
pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: receiving.id, receivingWarehouseName: receiving.warehouseName,
|
||||
channelCode: channel.channelCode, channelName: channel.channelName,
|
||||
pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER',
|
||||
customsInspection: null, inboundCode, ...destination,
|
||||
cargoes: Array.from({ length: cargoCount }, (_, index) => ({
|
||||
cargoName: '', packageType: 'CARTON', packageCount: index + 1, grossWeightKg: 0, volumeCbm: 0,
|
||||
currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${index + 1}-${marker}`,
|
||||
totalQuantity: index + 1, unitsPerPackage: 1,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses(type) {
|
||||
const all = []
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await jsonRequest(`/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`)
|
||||
requireSuccess(response)
|
||||
all.push(...pageRecords(response.data))
|
||||
if (page >= Number(response.data?.pages ?? 1)) return all
|
||||
}
|
||||
}
|
||||
|
||||
async function step(name, action) {
|
||||
const startedAt = new Date().toISOString()
|
||||
try {
|
||||
const evidence = await action()
|
||||
report.steps.push({ name, passed: true, startedAt, completedAt: new Date().toISOString(), evidence })
|
||||
return evidence
|
||||
} catch (error) {
|
||||
report.steps.push({ name, passed: false, startedAt, completedAt: new Date().toISOString(), error: sanitizeError(error) })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function rawRequest(path, { method = 'GET', body, headers = {}, authenticated = true } = {}) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
const requestHeaders = { ...headers }
|
||||
if (authenticated && token) requestHeaders.Authorization = `Bearer ${token}`
|
||||
let requestBody = body
|
||||
if (body != null && !(body instanceof FormData)) {
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
try {
|
||||
return await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: controller.signal })
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function jsonRequest(path, options) {
|
||||
const response = await rawRequest(path, options)
|
||||
const text = await response.text()
|
||||
let json
|
||||
try { json = text ? JSON.parse(text) : {} }
|
||||
catch { throw new Error(`${path} returned non-JSON HTTP ${response.status}`) }
|
||||
if (!response.ok) throw new Error(`${path} HTTP ${response.status} code=${json.code ?? '—'} requestId=${json.requestId ?? '—'} traceId=${json.traceId ?? '—'}`)
|
||||
return json
|
||||
}
|
||||
|
||||
function requireSuccess(response) {
|
||||
if (String(response?.code) !== '200') {
|
||||
throw new Error(`business failure code=${response?.code ?? '—'} message=${response?.message ?? '—'} requestId=${response?.requestId ?? '—'} traceId=${response?.traceId ?? '—'}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(values) {
|
||||
const result = {}
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index]
|
||||
if (value === '--help') { result.help = true; continue }
|
||||
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
|
||||
const key = value.slice(2)
|
||||
const next = values[++index]
|
||||
if (!next || next.startsWith('--')) throw new Error(`--${key} requires a value`)
|
||||
result[key] = next
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function trimSlash(value) { return String(value ?? '').replace(/\/+$/, '') }
|
||||
function compactTimestamp() { return new Date().toISOString().replace(/\D/g, '').slice(0, 14) }
|
||||
function pageRecords(value) { return value?.records ?? value?.content ?? value?.items ?? [] }
|
||||
function arrayData(value) { return Array.isArray(value) ? value : [] }
|
||||
function assert(condition, message) { if (!condition) throw new Error(message) }
|
||||
function sanitizeError(error) { return { name: error?.name ?? 'Error', message: String(error?.message ?? error) } }
|
||||
Reference in New Issue
Block a user