Merge remote-tracking branch 'origin/dev' into dev
# Conflicts: # deploy/sql/海外区域统一增量-MySQL8.sql # lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java # lastleg-service/src/main/resources/db/lastleg.sql # lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml # lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java # scripts/README.md
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@
|
||||
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
|
||||
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
|
||||
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` |
|
||||
| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,分别支持预检、真实字段草稿和显式模拟测量到待分柜;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 |
|
||||
| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,支持预检、真实字段草稿、批量提交到待审核和少量完整测量到待分柜;待审核阶段不审核且不使用托盘/库位;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 |
|
||||
| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 |
|
||||
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 |
|
||||
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
|
||||
|
||||
+9
-8
@@ -69,7 +69,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 测试环境 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` |
|
||||
| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建缺失字段留空的草稿,`wait-split` 显式允许测试占位后走正式审核测量。只复用两个原有托盘,不新增主档或库位。参数、限制及重跑说明见 `real-order-xlsx-import.md` |
|
||||
| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` |
|
||||
| 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 |
|
||||
| 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 |
|
||||
| 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 入库测量与计费测试环境 API 验收,会写业务数据 |
|
||||
@@ -80,7 +80,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 |
|
||||
| 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 |
|
||||
| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 |
|
||||
| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内 PDA Node 全量测试,不访问 Gateway |
|
||||
| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway |
|
||||
| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 |
|
||||
| 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 |
|
||||
| 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 |
|
||||
@@ -89,7 +89,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 契约数据 | `synchronous-dependency-baseline.mjs` | 同步服务依赖合同的数据源,只由相邻合同测试导入,不是 CLI |
|
||||
| OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 |
|
||||
| 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 |
|
||||
| 本地环境 | `start-gateway-overseas-local.ps1` | 仅启动本地 gateway-overseas,并将 Lastleg IAM 地址固定为 `127.0.0.1:9303`;会启动进程但不修改数据库 |
|
||||
| 本地环境 | `start-gateway-overseas-local.ps1` | 使用本地 Maven 启动海外 Gateway,并将海外安全投影地址指向本机;会启动本地 Java 进程,不访问或修改共享环境 |
|
||||
| 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 |
|
||||
| Nacos 环境选择 | `use-nacos-environment.ps1` | 按显式环境与区域设置当前 PowerShell 进程的 Nacos、Spring Profile 和运行区域变量;开发/测试密码必须由参数或环境变量提供,不发布配置 |
|
||||
| Nacos 配置发布 | `publish-nacos-config.ps1` | 将 `config/nacos/{local|cn|overseas}/*.yml` 发布到显式指定的 Nacos;非本地环境强制要求非 public namespace,会修改目标 Nacos 配置,执行前须取得对应环境授权 |
|
||||
@@ -155,7 +155,7 @@ node scripts/create-pending-review-orders.mjs
|
||||
| 检查 | 入口 |
|
||||
|---|---|
|
||||
| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` |
|
||||
| Admin/Client/国内 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` |
|
||||
| Admin/Client/国内与海外 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` |
|
||||
| PDA 本地页面和状态定向测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
|
||||
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
|
||||
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 |
|
||||
@@ -184,7 +184,7 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
|
||||
|
||||
## 前端全量回归
|
||||
|
||||
`frontend-full-regression.ps1` 编排本地 Admin/Client 的 `pnpm test`、`pnpm typecheck`,并默认执行国内 PDA 的 `node --test tests/*.test.mjs`;不调用 Gateway、不部署、不连接数据库。
|
||||
`frontend-full-regression.ps1` 编排本地 Admin/Client 的 `pnpm test`、`pnpm typecheck`,并默认执行国内与海外 PDA 的 `node --test tests/*.test.mjs`;不调用 Gateway、不部署、不连接数据库。
|
||||
|
||||
```powershell
|
||||
.\scripts\frontend-full-regression.ps1 -DryRun
|
||||
@@ -237,18 +237,19 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
|
||||
|
||||
### 本地 MySQL 全量验收
|
||||
|
||||
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `00E8F9C24CE16FE6B4D2B6AF60460AE1AF1D264DFE110D4BDCE611F09EB69EAA` 和 15403 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
|
||||
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;任何非本机地址都会立即以退出码 2 拒绝。非 dry-run 默认连续执行两轮,每轮精确删除 12 个 `baiyue_*` 国内/海外业务库,再只执行 `deploy/sql/头程数据库统一增量-MySQL8.sql` 和 `deploy/sql/海外区域统一增量-MySQL8.sql`,最后核对两个文件的最终 `PASS` 与 12 库完整性。
|
||||
|
||||
```powershell
|
||||
.\scripts\local-db-full-acceptance.ps1 -DryRun
|
||||
.\scripts\local-db-full-acceptance.ps1
|
||||
.\scripts\local-db-full-acceptance.ps1 -Rounds 1
|
||||
```
|
||||
|
||||
Dry-run 只校验 SQL 输入并打印空库、复跑、两类 picking 旧结构、WAIT_SPLIT/IAM、海外仓旧结构、中断恢复和最终结构的执行顺序,不启动 MySQL、不写库。真实运行每阶段写入 `.codex-tmp/p23-04-acceptance/acceptance-run-*.log`,不使用 `--force`,任一 SQL 错误立即停止并透传退出码,最后执行 `verify-final.sql`。脚本不保存或传入数据库密码,使用本机默认认证环境。
|
||||
Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行数、清理范围和执行顺序,不连接 MySQL。真实运行会先把中文文件名按原始字节暂存为英文文件名,规避 Windows MySQL 客户端的 `source` 路径问题;每轮日志写入 `.codex-tmp/local-db-full-acceptance/round-*/`。脚本不保存数据库密码,认证由本机登录配置或当前进程的 `MYSQL_PWD` 等外部方式提供。成功后保留第二轮完整初始化结果;失败时可能留下半初始化库,下次运行会先清理精确范围再重建。
|
||||
|
||||
仓库中 `phase2-test-env-acceptance.ps1`、`admin-test-env-acceptance.mjs`、`client-test-env-acceptance.mjs`、`export-center-test-env-acceptance.ps1` 等属于测试环境/API 验收脚本;它们可能读取或写入测试环境数据,不由本入口调用。`migrate-local-qualification-file.ps1`、`local-env.ps1` 等是环境/迁移辅助脚本,也不由本入口调用。
|
||||
|
||||
本入口不读取数据库凭据、不执行 MySQL、HTTP、迁移、DDL、INSERT/UPDATE/DELETE,也不启动 Nacos、Gateway 或业务服务。需要数据库矩阵时必须单独运行受控验收流程。
|
||||
本入口只操作明确指定的本机 MySQL,不访问开发或测试 HTTP 环境,不启动 Nacos、Gateway 或业务服务,也不会清理系统库或非 `baiyue_*` 数据库。
|
||||
|
||||
## 退出码
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['orders', ['merchant-qualification-fixture']],
|
||||
['inbound-hold', ['orders']],
|
||||
['inbound-measurement', ['orders']],
|
||||
['inbound-fees', ['inbound-measurement']],
|
||||
['manual-order-splitting-fixture', ['inbound-measurement']],
|
||||
['coload-groups', ['inbound-measurement']],
|
||||
['phase3-container-loading', ['inbound-measurement']],
|
||||
@@ -69,7 +70,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['phase3-waiting-for-loading', ['phase3-outbound-dispatch']],
|
||||
['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']],
|
||||
['phase3-order-splitting', ['phase3-loading-finalize']],
|
||||
['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']],
|
||||
['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents']],
|
||||
['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']],
|
||||
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
|
||||
['phase4-overseas-pda', ['phase4-tail-order-controls']],
|
||||
@@ -1381,6 +1382,7 @@ export class AcceptanceRun {
|
||||
inboundId: fixture.inboundId,
|
||||
boxCode: fixture.boxCode,
|
||||
sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [],
|
||||
sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [],
|
||||
fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD',
|
||||
skuCodes: Array.isArray(fixtureOrder.skuCodes) ? fixtureOrder.skuCodes : [],
|
||||
skuLines: Array.isArray(fixtureOrder.skuLines) ? fixtureOrder.skuLines : [],
|
||||
@@ -1570,23 +1572,33 @@ export class AcceptanceRun {
|
||||
if (Number(readiness().dropshipContainerCount || 0) < 1) {
|
||||
await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true)
|
||||
}
|
||||
if (Number(readiness().fbaContainerCount || 0) < 2) {
|
||||
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', false)
|
||||
const hasStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
|
||||
.some((item) => item?.destinationType === 'FBA'
|
||||
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
|
||||
&& String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
|
||||
if (!hasStandardFbaProducer()) {
|
||||
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true)
|
||||
}
|
||||
if (Number(readiness().overseasContainerCount || 0) < 1) {
|
||||
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true)
|
||||
}
|
||||
if (Number(readiness().distinctContainerCount || 0) < 4) {
|
||||
const hasSecondaryProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
|
||||
.some((item) => String(item?.resourceKey || '').includes('-P4-SECONDARY:'))
|
||||
if (!hasSecondaryProducer()) {
|
||||
await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false)
|
||||
}
|
||||
const matrix = this.createdResources.phase4InboundProducerFixtures
|
||||
const containers = Array.isArray(matrix?.containers) ? matrix.containers : []
|
||||
const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
const standardFbaReady = containers.find((item) => item?.destinationType === 'FBA'
|
||||
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
|
||||
&& String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
|
||||
const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS')
|
||||
const awaitingVariants = containers.filter((item) => ['FBA-STANDARD', 'SECONDARY']
|
||||
const awaitingVariants = containers.filter((item) => ['SECONDARY']
|
||||
.some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`)))
|
||||
const shippingMatrixReady = dropshipReady?.shippingReady === true
|
||||
&& Boolean(dropshipReady?.carrierScheduleQueryId)
|
||||
&& standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId)
|
||||
&& overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId)
|
||||
&& awaitingVariants.length >= 1
|
||||
&& awaitingVariants.every((item) => item.shippingReady === false
|
||||
@@ -1778,7 +1790,7 @@ export class AcceptanceRun {
|
||||
continue
|
||||
}
|
||||
let restoreFixture = null
|
||||
if (module === 'phase4-overseas-pda') {
|
||||
if (module === 'phase4-tail-order-controls' || module === 'phase4-overseas-pda') {
|
||||
try {
|
||||
await this.ensurePhase4PdaProducerMatrix()
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,13 @@ test('finance fixture module is independently exported and marker isolated', ()
|
||||
|
||||
test('fixture covers weight, volume, manual order, and container allocation contracts', () => {
|
||||
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM', 'MANUAL_ORDER_FEE', 'CONTAINER_SHARED']) assert.match(source, new RegExp(basis))
|
||||
assert.match(source, /chargeMode: pricingProfile\.chargeUnit/)
|
||||
assert.match(source, /warehouse-admin\?tenantId=0&page=/)
|
||||
assert.match(source, /item\?\.internalCode && item\?\.warehouseCode/)
|
||||
assert.match(source, /fbaInternalCodes\.has\(String\(rate\.destinationCode\)/)
|
||||
assert.match(source, /customer-rules\/versions\?tenantId=/)
|
||||
assert.match(source, /taxMode: 'TAX_EXCLUDED'/)
|
||||
assert.match(source, /inbound\?\.summary\?\.businessType \|\| profile\.businessType/)
|
||||
assert.match(source, /direction === 'PAYABLE' \? \{ settlementMode: 'ON_ACCOUNT' \}/)
|
||||
assert.match(source, /supplierId: id\(supplier\.id\), settlementMode: 'ON_ACCOUNT'/)
|
||||
assert.match(source, /request\(context, 'POST', '\/api\/platform\/finance\/v1\/cost-entries'/)
|
||||
@@ -123,6 +130,7 @@ function hookSet({ missing = false } = {}) {
|
||||
let automaticIndex = 0
|
||||
return {
|
||||
loadPricingEvidence: async () => ({ activeVersionIds: ['PRICE-1'] }),
|
||||
ensureCustomerChargeRule: async () => ({ id: 'RULE-CBM', chargeMode: 'CBM' }),
|
||||
createOrder: async (context, suffix) => ({ marker: `${context.marker}-${suffix}`, tenantId: '701',
|
||||
orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }),
|
||||
measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }),
|
||||
@@ -153,7 +161,7 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
|
||||
await run(context)
|
||||
const fixture = context.createdResources.financeFullFixtures
|
||||
assert.equal(fixture.customerId, '701')
|
||||
assert.equal(fixture.orders.length, 5)
|
||||
assert.equal(fixture.orders.length, 6)
|
||||
assert.equal(fixture.orders[0].orderNo, 'NO-AUTO1')
|
||||
assert.equal(fixture.orders[0].waybillId, 'WB-1')
|
||||
assert.equal(fixture.orders[0].actualWeightKg, 12.5)
|
||||
|
||||
@@ -55,8 +55,18 @@ async function createOrder(context, suffix, pricingProfile = null) {
|
||||
// The orders module discovers candidates independently. Reorder only those
|
||||
// real responses so its payload uses one exact active price-book row.
|
||||
globalThis.fetch = async (input, init = {}) => {
|
||||
const response = await originalFetch(input, init)
|
||||
const url = String(input)
|
||||
let effectiveInit = init
|
||||
if ((init.method || 'GET') === 'POST'
|
||||
&& url.includes('/api/merchant/v1/first-leg/pricing/estimate')
|
||||
&& typeof init.body === 'string') {
|
||||
const body = JSON.parse(init.body)
|
||||
effectiveInit = {
|
||||
...init,
|
||||
body: JSON.stringify({ ...body, chargeMode: pricingProfile.chargeUnit })
|
||||
}
|
||||
}
|
||||
const response = await originalFetch(input, effectiveInit)
|
||||
if ((init.method || 'GET') === 'GET' && (url.includes('/api/merchant/v1/warehouses?')
|
||||
|| url.includes('/api/merchant/v1/first-leg/pricing/channels?'))) {
|
||||
const body = await response.clone().json().catch(() => null)
|
||||
@@ -99,7 +109,7 @@ async function measureOrder(context, resource) {
|
||||
if (context.financeFixtureHooks?.measureOrder) return context.financeFixtureHooks.measureOrder(context, resource)
|
||||
const originalMarker = context.marker
|
||||
const createdKeys = ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'allowAnyExistingPallet']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'allowAnyExistingPallet', 'waitSplitVariant']
|
||||
const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }]))
|
||||
const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(context.context, key), value: context.context[key] }]))
|
||||
context.marker = resource.marker
|
||||
@@ -114,6 +124,13 @@ async function measureOrder(context, resource) {
|
||||
// Reuse that warehouse's real pallet master instead of requiring the bulk-data
|
||||
// generator's two fixed Yiwu pallet codes.
|
||||
context.context.allowAnyExistingPallet = true
|
||||
context.context.waitSplitVariant = {
|
||||
...(context.context.waitSplitVariant || {}),
|
||||
// The production pricing service chooses the dominant real billing basis.
|
||||
// Keep the CBM sample's measured gross weight below its measured volume
|
||||
// weight so the fixture proves the actual VOLUME_CBM path.
|
||||
grossWeightPerPalletKg: resource.pricingProfile?.chargeUnit === 'CBM' ? 20 : 100,
|
||||
}
|
||||
try {
|
||||
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
|
||||
await measurement.run(context)
|
||||
@@ -281,6 +298,23 @@ async function uploadVoucher(context, customerId) {
|
||||
|
||||
async function loadPricingEvidence(context) {
|
||||
if (context.financeFixtureHooks?.loadPricingEvidence) return context.financeFixtureHooks.loadPricingEvidence(context)
|
||||
const fbaInternalCodes = new Set()
|
||||
let warehousePage = 1
|
||||
let warehouseTotal = 0
|
||||
do {
|
||||
const warehouseResponse = await context.request('GET',
|
||||
`/api/platform/v1/warehouse-admin?tenantId=0&page=${warehousePage}&size=200&warehouseType=FBA&status=ACTIVE`)
|
||||
const warehouseResult = context.apiSuccess(MODULE, `读取具备内部编号的 FBA 仓第 ${warehousePage} 页`,
|
||||
warehouseResponse, '', OPTIONS)
|
||||
const warehouses = rows(warehouseResult)
|
||||
warehouseTotal = Number(warehouseResult?.total || warehouses.length)
|
||||
warehouses.filter((item) => item?.internalCode && item?.warehouseCode)
|
||||
.forEach((item) => fbaInternalCodes.add(String(item.warehouseCode).trim().toUpperCase()))
|
||||
if (warehouses.length === 0) break
|
||||
warehousePage += 1
|
||||
} while ((warehousePage - 1) * 200 < warehouseTotal)
|
||||
context.assert(MODULE, '自动计费候选使用已配置内部编号的 FBA 仓', null,
|
||||
fbaInternalCodes.size > 0, `warehouses=${fbaInternalCodes.size}`, OPTIONS)
|
||||
const response = await context.request('GET', '/api/platform/v1/fba-pricing/carrier-rates/price-books?status=ACTIVE')
|
||||
const versions = context.apiSuccess(MODULE, '读取真实生效价格配置', response, '', OPTIONS)
|
||||
const active = rows(versions).filter((item) => item?.status === 'ACTIVE')
|
||||
@@ -291,6 +325,7 @@ async function loadPricingEvidence(context) {
|
||||
const rates = Array.isArray(detail?.rates) ? detail.rates : []
|
||||
for (const rate of rates) {
|
||||
if (rate?.destinationType !== 'FBA' || !rate?.originWarehouseId || !rate?.channelCode || !rate?.pricingTaxMode || !rate?.destinationCode) continue
|
||||
if (!fbaInternalCodes.has(String(rate.destinationCode).trim().toUpperCase())) continue
|
||||
if (!['KG', 'CBM'].includes(rate.chargeUnit) || profiles.some((item) => item.chargeUnit === rate.chargeUnit)) continue
|
||||
const matches = rates.filter((item) => id(item.originWarehouseId) === id(rate.originWarehouseId)
|
||||
&& item.channelCode === rate.channelCode && item.destinationType === rate.destinationType
|
||||
@@ -299,7 +334,8 @@ async function loadPricingEvidence(context) {
|
||||
if (matches.length !== 1) continue
|
||||
profiles.push({ versionId: id(version.id), rateId: id(rate.id), chargeUnit: rate.chargeUnit,
|
||||
originWarehouseId: id(rate.originWarehouseId), channelCode: rate.channelCode,
|
||||
pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode })
|
||||
pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode,
|
||||
businessType: rate.businessType || 'FBA' })
|
||||
}
|
||||
}
|
||||
context.assert(MODULE, 'ACTIVE 价格簿包含 KG 与 CBM 可用真实组合', response,
|
||||
@@ -308,6 +344,57 @@ async function loadPricingEvidence(context) {
|
||||
return { activeVersionIds: active.map((item) => id(item.id)).filter(Boolean), profiles }
|
||||
}
|
||||
|
||||
async function ensureCustomerChargeRule(context, resource, profile) {
|
||||
if (context.financeFixtureHooks?.ensureCustomerChargeRule) {
|
||||
return context.financeFixtureHooks.ensureCustomerChargeRule(context, resource, profile)
|
||||
}
|
||||
if (!resource?.tenantId || profile?.pricingTaxMode !== 'TAX_EXCLUDED') return null
|
||||
const tenantId = id(resource.tenantId)
|
||||
const inboundResponse = await context.request('GET',
|
||||
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const inbound = context.apiSuccess(MODULE, '读取不包税入库计费业务类型', inboundResponse,
|
||||
`inboundId=${resource.inboundId}`, OPTIONS)
|
||||
const businessType = inbound?.summary?.businessType || profile.businessType
|
||||
if (!businessType) {
|
||||
blocker(context, '取得不包税入库计费业务类型',
|
||||
`inboundId=${resource.inboundId} 未返回 businessType,不能创建猜测的客户计费规则`)
|
||||
return null
|
||||
}
|
||||
const listPath = `/api/platform/v1/fba-pricing/customer-rules?tenantId=${encodeURIComponent(tenantId)}&customerId=${encodeURIComponent(tenantId)}`
|
||||
const listResponse = await context.request('GET', listPath)
|
||||
const listed = context.apiSuccess(MODULE, '查询不包税客户计费规则', listResponse,
|
||||
`customerId=${tenantId}, businessType=${businessType}`, OPTIONS)
|
||||
const applicable = rows(listed).filter((rule) => rule?.status === 'ACTIVE'
|
||||
&& rule?.businessType === businessType && rule?.taxMode === 'TAX_EXCLUDED')
|
||||
let desired = applicable.find((rule) => rule?.chargeMode === profile.chargeUnit)
|
||||
if (!desired) {
|
||||
const effectiveFrom = new Date(Date.now() - 86_400_000).toISOString().slice(0, 19)
|
||||
const body = {
|
||||
ruleCode: `FIN_P1_${profile.chargeUnit}_${tenantId}`.slice(0, 50), customerId: tenantId,
|
||||
customerName: String(inbound?.summary?.customerName || `财务P1验收客户-${tenantId}`).slice(0, 128), businessType,
|
||||
chargeMode: profile.chargeUnit, taxMode: 'TAX_EXCLUDED', conversionFactor: 1,
|
||||
roundingScale: 3, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom,
|
||||
effectiveTo: null, remark: `${context.marker}-VOLUME_CBM`
|
||||
}
|
||||
const createResponse = await request(context, 'POST',
|
||||
`/api/platform/v1/fba-pricing/customer-rules/versions?tenantId=${encodeURIComponent(tenantId)}`, body)
|
||||
desired = context.apiSuccess(MODULE, '创建不包税客户计费规则', createResponse,
|
||||
`customerId=${tenantId}, chargeMode=${profile.chargeUnit}`, OPTIONS)
|
||||
}
|
||||
const needsDefault = applicable.length > 1
|
||||
|| (applicable.length === 1 && id(applicable[0]?.id) !== id(desired?.id))
|
||||
if (desired?.id && needsDefault && desired.defaultRule !== true) {
|
||||
const defaultResponse = await request(context, 'PUT',
|
||||
`/api/platform/v1/fba-pricing/customer-rules/${encodeURIComponent(desired.id)}/default?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
context.apiSuccess(MODULE, '设置不包税客户唯一默认计费规则', defaultResponse,
|
||||
`ruleId=${desired.id}, chargeMode=${profile.chargeUnit}`, OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, '不包税自动计费具备客户计费方式', listResponse,
|
||||
Boolean(desired?.id) && desired?.chargeMode === profile.chargeUnit,
|
||||
`customerId=${tenantId}, chargeMode=${desired?.chargeMode || 'missing'}, ruleId=${id(desired?.id) || 'missing'}`, OPTIONS)
|
||||
return desired || null
|
||||
}
|
||||
|
||||
async function createMarkerContainer(context, resource) {
|
||||
if (context.financeFixtureHooks?.createMarkerContainer) return context.financeFixtureHooks.createMarkerContainer(context, resource)
|
||||
if (!resource) {
|
||||
@@ -387,12 +474,15 @@ export async function run(context) {
|
||||
const pricingEvidence = await loadPricingEvidence(context)
|
||||
const resources = []
|
||||
const profiles = pricingEvidence.profiles || []
|
||||
for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL'].entries()) {
|
||||
for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL', 'CONTAINER'].entries()) {
|
||||
const resource = await createOrder(context, suffix, profiles[index % profiles.length] || null)
|
||||
context.assert(MODULE, `创建 ${suffix} marker 隔离订单与入库单`, null, Boolean(resource),
|
||||
resource ? `orderId=${resource.orderId}, inboundId=${resource.inboundId}` : `marker=${context.marker}-${suffix} 未返回独立入库单`, OPTIONS)
|
||||
if (resource) resources.push(resource)
|
||||
}
|
||||
for (const resource of resources.filter((item) => item.pricingProfile?.pricingTaxMode === 'TAX_EXCLUDED')) {
|
||||
await ensureCustomerChargeRule(context, resource, resource.pricingProfile)
|
||||
}
|
||||
const measured = []
|
||||
for (const resource of resources) {
|
||||
const result = await measureOrder(context, resource)
|
||||
@@ -430,7 +520,7 @@ export async function run(context) {
|
||||
if (fee) fixtures.push(fee)
|
||||
}
|
||||
}
|
||||
const manualResources = [measured.at(-1)].filter(Boolean)
|
||||
const manualResources = [measured[4]].filter(Boolean)
|
||||
if (feeItems.PAYABLE && supplier?.id) {
|
||||
for (const [index, manualResource] of manualResources.entries()) {
|
||||
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
|
||||
@@ -440,7 +530,11 @@ export async function run(context) {
|
||||
}
|
||||
}
|
||||
|
||||
const container = (measured[0] ? await createMarkerContainer(context, measured[0]) : null)
|
||||
// A source whose fee has already been sent is intentionally frozen against
|
||||
// loading-plan membership changes. Use a dedicated measured order for the
|
||||
// container fixture so finance setup never conflicts with that invariant.
|
||||
const containerResource = measured[5]
|
||||
const container = (containerResource ? await createMarkerContainer(context, containerResource) : null)
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
const containerOwned = (container?.marker === context.marker || String(container?.marker || '').startsWith(`${context.marker}-`))
|
||||
&& container?.loadingPlanId && container?.containerBookingId
|
||||
|
||||
@@ -34,7 +34,7 @@ test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段',
|
||||
|
||||
test('现行 Admin 履约列表均以左固定操作列开头', () => {
|
||||
const sources = [
|
||||
['container-loading/pages/ContainersView.vue', 1],
|
||||
['container-loading/pages/ContainersView.vue', 2],
|
||||
['loading-lists/components/LoadingListsView.vue', 2],
|
||||
['ocean-bookings/pages/OceanBookingsView.vue', 1],
|
||||
['port-schedules/pages/PortSchedulesView.vue', 1],
|
||||
|
||||
@@ -32,10 +32,28 @@ test('initial fee preparation is reusable without confirming or publishing the f
|
||||
assert.match(helper, /\/fees\/latest/)
|
||||
assert.match(helper, /\/fees\/calculate/)
|
||||
assert.match(helper, /feeStatus === 'CALCULATED'/)
|
||||
assert.match(helper, /\$\{inboundId\}-inbound-fee-initial-calculate/)
|
||||
assert.doesNotMatch(helper, /\/fees\/confirm|\/fees\/send/)
|
||||
})
|
||||
|
||||
test('automatic fee adjustment confirms the ten percent tolerance boundary', async () => {
|
||||
const source = await readFile(moduleFile, 'utf8')
|
||||
assert.match(source, /reason: `\$\{context\.marker\}-自动费用版本调整`, toleranceConfirmed: true/)
|
||||
assert.match(source, /adjustedUnitPrice = Number\(\(Math\.max\(0, money\(initial\.unitPrice\)\) \+ 0\.37\)\.toFixed\(4\)\)/)
|
||||
assert.match(source, /人工对客单价写入最新费用版本/)
|
||||
assert.match(source, /money\(published\?\.unitPrice\) === adjustedUnitPrice/)
|
||||
})
|
||||
|
||||
test('customer confirms chargeable quantity and manual unit price in one locked version', async () => {
|
||||
const source = await readFile(moduleFile, 'utf8')
|
||||
assert.match(source, /const orderId = id\(fixture\?\.orderId\)/)
|
||||
assert.match(source, /context\.context\?\.merchantQualificationFixture/)
|
||||
assert.match(source, /merchantFixture\?\.username/)
|
||||
assert.match(source, /merchantFixture\?\.password/)
|
||||
assert.doesNotMatch(source, /const orderId = id\(context\.createdResources\.orders\?\.orderId\)/)
|
||||
assert.match(source, /Client 读取已推送计费量、对客单价和费用合计/)
|
||||
assert.match(source, /Client 一次确认计费量、对客单价和费用/)
|
||||
assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/)
|
||||
assert.match(source, /客户费用确认重放返回同一锁定版本/)
|
||||
assert.match(source, /客户确认后平台不得再次修改计费量或对客单价/)
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function ensureInitialCalculated(context, fixture,
|
||||
if (!success(initialResponse) && String(initialResponse?.body?.code) === 'A0404') {
|
||||
initialResponse = await context.request('POST',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/calculate?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-initial-calculate`))
|
||||
undefined, undefined, requestHeaders(`${context.marker}-${inboundId}-inbound-fee-initial-calculate`))
|
||||
}
|
||||
const initial = context.apiSuccess(MODULE, '读取或补齐审核后初始费用', initialResponse, '', options)
|
||||
context.assert(MODULE, '初始费用保持 WAIT_SPLIT 且尚未发布', initialResponse,
|
||||
@@ -80,7 +80,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const adjustedQuantity = Math.max(0.0001, money(initial.chargeableQuantity) + 0.01)
|
||||
const adjustedUnitPrice = Math.max(0, money(initial.unitPrice))
|
||||
const adjustedUnitPrice = Number((Math.max(0, money(initial.unitPrice)) + 0.37).toFixed(4))
|
||||
const adjustedAdditional = money(initial.additionalAmount) + 0.01
|
||||
const adjustResponse = await context.request('PUT',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
@@ -93,6 +93,9 @@ export async function run(context) {
|
||||
Number(adjusted?.feeVersion) > Number(initial.feeVersion) && adjusted?.feeStatus === 'CALCULATED'
|
||||
&& adjusted?.nextProcessStatus === 'WAIT_SPLIT' && money(adjusted?.totalAmount) >= adjustedAdditional,
|
||||
`oldVersion=${initial.feeVersion}, newVersion=${adjusted?.feeVersion ?? 'missing'}, status=${adjusted?.feeStatus || 'missing'}, process=${adjusted?.nextProcessStatus || 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '人工对客单价写入最新费用版本', adjustResponse,
|
||||
money(adjusted?.unitPrice) === adjustedUnitPrice,
|
||||
`referenceUnitPrice=${initial.unitPrice ?? 'missing'}, selectedUnitPrice=${adjustedUnitPrice}, persistedUnitPrice=${adjusted?.unitPrice ?? 'missing'}`, OPTIONS)
|
||||
if (!adjusted?.feeId) { finish(context, start); return }
|
||||
|
||||
const adjustedDetails = await listDetails(context, tenantId, inboundId, '回查调整后的派生详情版本')
|
||||
@@ -122,9 +125,71 @@ export async function run(context) {
|
||||
&& id(row.sourceFeeId) === id(sent?.feeId))
|
||||
context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse,
|
||||
sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT'
|
||||
&& published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED',
|
||||
&& money(sent?.unitPrice) === adjustedUnitPrice
|
||||
&& published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED'
|
||||
&& money(published?.unitPrice) === adjustedUnitPrice
|
||||
&& money(published?.amount) === money(sent?.totalAmount),
|
||||
`status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const orderId = id(fixture?.orderId)
|
||||
const merchantFixture = context.context?.merchantQualificationFixture
|
||||
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
|
||||
loginId: merchantFixture?.username, password: merchantFixture?.password,
|
||||
}, '')
|
||||
const merchantLogin = context.apiSuccess(MODULE, 'Client 登录并准备确认计费量和费用', merchantLoginResponse,
|
||||
`orderId=${orderId || 'missing'}`, OPTIONS)
|
||||
const merchantToken = merchantLogin?.accessToken
|
||||
context.assert(MODULE, '本轮客户订单与登录令牌可用于费用确认', merchantLoginResponse,
|
||||
Boolean(orderId && merchantToken), `orderId=${orderId || 'missing'}, token=${merchantToken ? 'present' : 'missing'}`, OPTIONS)
|
||||
if (!orderId || !merchantToken) { finish(context, start); return }
|
||||
|
||||
const merchantBillingPath = `/api/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing`
|
||||
const merchantBillingResponse = await context.request('GET', merchantBillingPath, undefined, merchantToken)
|
||||
const merchantBilling = context.apiSuccess(MODULE, 'Client 读取已推送计费量、对客单价和费用合计',
|
||||
merchantBillingResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '客户看到的平台推送版本、对客单价和合计完全一致', merchantBillingResponse,
|
||||
Number(merchantBilling?.latestFee?.feeVersion) === Number(sent.feeVersion)
|
||||
&& merchantBilling?.latestFee?.feeStatus === 'SENT'
|
||||
&& money(merchantBilling?.latestFee?.unitPrice) === adjustedUnitPrice
|
||||
&& money(merchantBilling?.latestFee?.chargeableQuantity) === money(sent.chargeableQuantity)
|
||||
&& money(merchantBilling?.latestFee?.totalAmount) === money(sent.totalAmount),
|
||||
`version=${merchantBilling?.latestFee?.feeVersion ?? 'missing'}, unitPrice=${merchantBilling?.latestFee?.unitPrice ?? 'missing'}, total=${merchantBilling?.latestFee?.totalAmount ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const customerConfirmKey = `${context.marker}-customer-fee-confirm`.slice(0, 128)
|
||||
const customerConfirmResponse = await context.request('POST', `${merchantBillingPath}/confirm`,
|
||||
{ expectedFeeVersion: Number(sent.feeVersion) }, merchantToken, requestHeaders(customerConfirmKey))
|
||||
const customerConfirmed = context.apiSuccess(MODULE, 'Client 一次确认计费量、对客单价和费用',
|
||||
customerConfirmResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '客户确认锁定原推送版本及其单价和合计', customerConfirmResponse,
|
||||
customerConfirmed?.customerConfirmationStatus === 'CONFIRMED'
|
||||
&& Number(customerConfirmed?.feeVersion) === Number(sent.feeVersion)
|
||||
&& money(customerConfirmed?.unitPrice) === adjustedUnitPrice
|
||||
&& money(customerConfirmed?.chargeableQuantity) === money(sent.chargeableQuantity)
|
||||
&& money(customerConfirmed?.totalAmount) === money(sent.totalAmount)
|
||||
&& Boolean(customerConfirmed?.customerConfirmedAt),
|
||||
`confirmation=${customerConfirmed?.customerConfirmationStatus || 'missing'}, version=${customerConfirmed?.feeVersion ?? 'missing'}, unitPrice=${customerConfirmed?.unitPrice ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const customerConfirmReplayResponse = await context.request('POST', `${merchantBillingPath}/confirm`,
|
||||
{ expectedFeeVersion: Number(sent.feeVersion) }, merchantToken, requestHeaders(customerConfirmKey))
|
||||
const customerConfirmReplay = context.apiSuccess(MODULE, 'Client 费用确认幂等重放',
|
||||
customerConfirmReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '客户费用确认重放返回同一锁定版本', customerConfirmReplayResponse,
|
||||
customerConfirmReplay?.customerConfirmationStatus === 'CONFIRMED'
|
||||
&& id(customerConfirmReplay?.feeId) === id(customerConfirmed?.feeId)
|
||||
&& Number(customerConfirmReplay?.feeVersion) === Number(customerConfirmed?.feeVersion),
|
||||
`feeId=${id(customerConfirmReplay?.feeId) || 'missing'}, version=${customerConfirmReplay?.feeVersion ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const lockedAdjustResponse = await context.request('PUT',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedFeeVersion: Number(sent.feeVersion), chargeableQuantity: money(sent.chargeableQuantity),
|
||||
unitPrice: Number((adjustedUnitPrice + 0.01).toFixed(4)), additionalAmount: money(sent.additionalAmount),
|
||||
reason: `${context.marker}-客户确认后禁止改单价`, toleranceConfirmed: true }, undefined,
|
||||
requestHeaders(`${context.marker}-locked-fee-adjust`))
|
||||
context.assert(MODULE, '客户确认后平台不得再次修改计费量或对客单价', lockedAdjustResponse,
|
||||
lockedAdjustResponse.http === 200 && !success(lockedAdjustResponse)
|
||||
&& String(lockedAdjustResponse.body?.code) === 'A0409',
|
||||
`code=${lockedAdjustResponse.body?.code ?? 'missing'}, message=${lockedAdjustResponse.body?.message || 'missing'}`, OPTIONS)
|
||||
|
||||
const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=200&status=ACTIVE')
|
||||
const feeItemsPage = context.apiSuccess(MODULE, '查询可用于其他应收的费用项目', feeItemsResponse, '', OPTIONS)
|
||||
const feeItems = Array.isArray(feeItemsPage?.records) ? feeItemsPage.records
|
||||
@@ -196,8 +261,9 @@ export async function run(context) {
|
||||
receivableTotal >= money(published?.amount) + money(manualV2?.amount) && payableTotal >= 0,
|
||||
`receivableTotal=${receivableTotal.toFixed(2)}, freight=${money(published?.amount).toFixed(2)}, manual=${money(manualV2?.amount).toFixed(2)}, payableTotal=${payableTotal.toFixed(2)}`, OPTIONS)
|
||||
|
||||
if (!customerConfirmed?.customerConfirmationStatus) { finish(context, start); return }
|
||||
context.createdResources.inboundFees = { marker: context.marker, tenantId, inboundId,
|
||||
freightFeeId: id(sent.feeId), freightVersion: sent.feeVersion, manualDetailId: id(manualV2.id),
|
||||
manualVersion: manualV2.sourceFeeVersion }
|
||||
manualVersion: manualV2.sourceFeeVersion, customerConfirmationStatus: customerConfirmed.customerConfirmationStatus }
|
||||
finish(context, start)
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ async function exerciseSystemPalletScenario(fault = '') {
|
||||
}
|
||||
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
|
||||
assert.deepEqual(sealed.sealedCartonMarks, ['CODE#2-1', 'CODE#2-2'])
|
||||
assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001'])
|
||||
return checks
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
|
||||
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
|
||||
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
|
||||
return { ...sealed, sealedCartonMarks }
|
||||
return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [palletNo] }
|
||||
}
|
||||
|
||||
async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) {
|
||||
@@ -1067,11 +1067,14 @@ export async function run(context) {
|
||||
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
|
||||
|
||||
let sealedCartonMarks = []
|
||||
let sealedSourcePalletNos = []
|
||||
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
|
||||
const sealed = await completeDropshipPalletization(context, {
|
||||
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
|
||||
})
|
||||
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
|
||||
sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
|
||||
? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
|
||||
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
|
||||
finalResponse = completed.response
|
||||
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
|
||||
@@ -1093,8 +1096,10 @@ export async function run(context) {
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.inboundMeasurement = {
|
||||
sealedCartonMarks,
|
||||
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
|
||||
sealedCartonMarks, sealedSourcePalletNos,
|
||||
marker: context.marker, tenantId, inboundId,
|
||||
orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId),
|
||||
measurementId: asId(draft.measurementId),
|
||||
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
|
||||
processStatus: finalMeasurement?.processStatus || null,
|
||||
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
|
||||
|
||||
@@ -3,6 +3,13 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const sql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
|
||||
const overseasSql = readFileSync(new URL('../../deploy/sql/海外区域统一增量-MySQL8.sql', import.meta.url), 'utf8')
|
||||
const lastlegBaseline = readFileSync(new URL('../../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8')
|
||||
const lastlegPermissionSeed = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8')
|
||||
const phase5DeliveryPermissionSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase5-delivery-permissions.sql', import.meta.url), 'utf8')
|
||||
const overseasWarehousePermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-warehouse/permissions.ts', import.meta.url), 'utf8')
|
||||
const overseasBusinessPermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-business/permissions.ts', import.meta.url), 'utf8')
|
||||
const overseasMasterDataPermissions = readFileSync(new URL('../../../apps/admin-web/src/modules/overseas-master-data/permissions.ts', import.meta.url), 'utf8')
|
||||
const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-admin-rbac.sql', import.meta.url), 'utf8')
|
||||
const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8')
|
||||
const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8')
|
||||
@@ -50,6 +57,93 @@ test('国内统一初始化支持真实空库且不越界创建海外业务库',
|
||||
)
|
||||
})
|
||||
|
||||
test('国内统一初始化在注释门禁前创建订单申报快照表', () => {
|
||||
for (const table of ['fl_order_declaration_snapshot', 'fl_order_declaration_event']) {
|
||||
const createIndex = sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table} (`)
|
||||
const commentIndex = sql.indexOf(`CALL queue_table_comment('baiyue_firstleg', '${table}'`)
|
||||
assert.ok(createIndex >= 0, `missing domestic baseline table: ${table}`)
|
||||
assert.ok(commentIndex >= 0, `missing domestic table comment gate: ${table}`)
|
||||
assert.ok(createIndex < commentIndex, `${table} must exist before the table-comment gate runs`)
|
||||
}
|
||||
})
|
||||
|
||||
test('海外统一初始化内联空库基线且不依赖模块 SQL', () => {
|
||||
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG/)
|
||||
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)
|
||||
assert.match(overseasSql, /CREATE DATABASE IF NOT EXISTS baiyue_lastleg/)
|
||||
assert.match(overseasSql, /CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse/)
|
||||
assert.match(overseasSql, /CREATE TABLE IF NOT EXISTS ll_order \(/)
|
||||
assert.match(overseasSql, /CREATE TABLE IF NOT EXISTS wh_location \(/)
|
||||
assert.ok(
|
||||
overseasSql.indexOf('CREATE TABLE IF NOT EXISTS ll_order (') <
|
||||
overseasSql.indexOf('CREATE TABLE IF NOT EXISTS ll_order_source_pallet ('),
|
||||
'll_order must exist before the source-pallet foreign key is created',
|
||||
)
|
||||
assert.ok(
|
||||
overseasSql.indexOf('CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse') <
|
||||
overseasSql.indexOf('CREATE TABLE IF NOT EXISTS wh_location ('),
|
||||
'the overseas warehouse schema must be selected before its baseline tables are created',
|
||||
)
|
||||
})
|
||||
|
||||
test('海外空库基线为固定平台管理员提供完整version0授权投影', () => {
|
||||
const authorityBlock = lastlegBaseline.match(
|
||||
/SET @platform_admin_overseas_authorities = JSON_ARRAY\(([\s\S]*?)\n\);/,
|
||||
)?.[1]
|
||||
assert.ok(authorityBlock, 'missing platform_admin overseas authority snapshot')
|
||||
const authorities = [...authorityBlock.matchAll(/'([^']+)'/g)].map((match) => match[1])
|
||||
const deferred = new Set([
|
||||
'platform:overseas-warehouse:carton:list',
|
||||
'platform:overseas-warehouse:carton:create',
|
||||
'platform:overseas-warehouse:carton:edit',
|
||||
'platform:overseas-warehouse:product:list',
|
||||
'platform:overseas-warehouse:product:create',
|
||||
'platform:overseas-warehouse:product:edit',
|
||||
])
|
||||
assert.equal(authorities.length, 105)
|
||||
assert.equal(new Set(authorities).size, authorities.length, 'baseline authorities must not contain duplicates')
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:finance:')).length, 34)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-business:')).length, 11)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-master-data:')).length, 15)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 45)
|
||||
for (const authority of authorities) {
|
||||
assert.ok(sql.includes(`'${authority}'`), `authority is absent from the domestic IAM baseline: ${authority}`)
|
||||
}
|
||||
for (const source of [overseasWarehousePermissions, overseasBusinessPermissions, overseasMasterDataPermissions]) {
|
||||
const declared = [...source.matchAll(/'((?:platform:overseas-warehouse|platform:overseas-business|platform:overseas-master-data):[^']+)'/g)]
|
||||
.map((match) => match[1]).filter((authority) => !deferred.has(authority))
|
||||
for (const authority of declared) {
|
||||
assert.ok(
|
||||
[lastlegPermissionSeed, phase5DeliveryPermissionSeed].some((seed) => seed.includes(`'${authority}'`)),
|
||||
`Admin permission is absent from the IAM source seed: ${authority}`,
|
||||
)
|
||||
assert.ok(authorities.includes(authority), `Admin permission is absent from the fixed-admin overseas snapshot: ${authority}`)
|
||||
}
|
||||
}
|
||||
for (const authority of deferred) {
|
||||
assert.ok(!lastlegPermissionSeed.includes(`'${authority}'`), `deferred permission entered the IAM source seed: ${authority}`)
|
||||
assert.ok(!authorities.includes(authority), `deferred permission entered the fixed-admin snapshot: ${authority}`)
|
||||
}
|
||||
for (const required of [
|
||||
'platform:overseas-warehouse:last-mile-order:list',
|
||||
'platform:overseas-warehouse:inbound-order:list',
|
||||
'platform:overseas-warehouse:dispatch:list',
|
||||
'platform:overseas-business:purchase:list',
|
||||
'platform:overseas-master-data:quote:list',
|
||||
'platform:finance:fee-source:review',
|
||||
]) assert.ok(authorities.includes(required), `missing fixed-admin overseas authority: ${required}`)
|
||||
|
||||
assert.match(lastlegBaseline, /SET @platform_admin_overseas_roles = JSON_ARRAY\('PLATFORM_ADMIN'\)/)
|
||||
assert.doesNotMatch(lastlegBaseline, /\('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY\(\),JSON_ARRAY\(\)/)
|
||||
assert.doesNotMatch(overseasSql, /\('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY\(\),JSON_ARRAY\(\)/)
|
||||
|
||||
const inline = overseasSql.match(
|
||||
/-- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG\r?\n([\s\S]*?)\r?\n\r?\n-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG/,
|
||||
)?.[1]
|
||||
assert.ok(inline, 'missing inline Lastleg baseline')
|
||||
assert.equal(inline.replace(/\r\n/g, '\n').trim(), lastlegBaseline.replace(/\r\n/g, '\n').trim())
|
||||
})
|
||||
|
||||
test('国内统一初始化补齐入库费用人工调整基线字段', () => {
|
||||
assert.match(sql, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/)
|
||||
assert.match(sql, /SET baseline_chargeable_quantity = chargeable_quantity[\s\S]*?WHERE baseline_chargeable_quantity IS NULL/)
|
||||
|
||||
@@ -57,17 +57,16 @@ test('fails closed when invoked outside the manual matrix wrapper', () => {
|
||||
assert.match(source, /missing=manualStateMatrix\.pages/)
|
||||
})
|
||||
|
||||
test('covers clearance through its public API and keeps customs on the dedicated customs-v1 flow', () => {
|
||||
assert.match(source, /new FormData\(\)/)
|
||||
assert.match(source, /form\.append\('file'/)
|
||||
assert.match(source, /form\.append\('expectedVersion'/)
|
||||
assert.match(source, /form\.append\('operationReason'/)
|
||||
assert.match(source, /'Idempotency-Key'/)
|
||||
assert.match(source, /clearancePath\}\/document\/upload/)
|
||||
assert.match(source, /clearancePath\}\/complete/)
|
||||
test('reuses the departed-container clearance action verified by the dedicated module', () => {
|
||||
assert.match(source, /createdResources\?\.phase3ContainerDocuments/)
|
||||
assert.match(source, /documentIds\?\.DESTINATION_CLEARANCE/)
|
||||
assert.match(source, /本轮离仓后已完成的目的港清关动作/)
|
||||
assert.match(source, /GET', `\$\{CONTAINERS\}\/\$\{containerId\}\/clearance`/)
|
||||
assert.match(source, /completed\?\.documentType === 'DESTINATION_CLEARANCE'/)
|
||||
assert.match(source, /Boolean\(completed\?\.completedAt\)/)
|
||||
assert.match(source, /matrix\.pages\.containerLoading\.actions/)
|
||||
assert.doesNotMatch(source, /CUSTOMS_DECLARATION|loading-customs|customsPath/)
|
||||
assert.match(source, /tasks\/\$\{taskId\}\/evidence/)
|
||||
assert.doesNotMatch(source, /internal\/file|business-files/)
|
||||
assert.doesNotMatch(source, /\{\s*fileId\s*:/)
|
||||
assert.doesNotMatch(source, /clearance\/document\/upload/)
|
||||
})
|
||||
|
||||
@@ -24,32 +24,6 @@ async function post(context, path, body, label) {
|
||||
{ 'Idempotency-Key': operationId(context, label) })
|
||||
}
|
||||
|
||||
async function uploadDocument(context, path, expectedVersion, operationReason, label) {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([`${context.marker}-${label}`], { type: 'text/plain' }), `${label}.txt`)
|
||||
form.append('expectedVersion', String(expectedVersion))
|
||||
if (operationReason) form.append('operationReason', operationReason)
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
||||
'Idempotency-Key': operationId(context, `${label}-upload`),
|
||||
},
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text()
|
||||
let body = null
|
||||
try { body = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method: 'POST', path, http: response.status, body, raw: body ? null : raw.slice(0, 500),
|
||||
requestId: body?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: body?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCompletionEvidence(context, chain, label) {
|
||||
const taskId = id(chain.task?.id)
|
||||
const warehouseId = id(chain.source?.warehouseId)
|
||||
@@ -351,34 +325,26 @@ function fixture(value, status, detailPath, extra = {}) {
|
||||
return { id: id(value), status, detailPath, ...extra }
|
||||
}
|
||||
|
||||
async function completeSimpleDocuments(context, chain, matrix) {
|
||||
const reason = `${context.marker} 人工页面动作验收`
|
||||
const clearancePath = `${CONTAINERS}/${chain.containerId}/clearance`
|
||||
const clearanceUpload = await uploadDocument(context, `${clearancePath}/document/upload`, 0,
|
||||
reason, 'container-clearance')
|
||||
const clearanceDocument = context.apiSuccess(MODULE, '货柜公开接口上传目的港清关资料',
|
||||
clearanceUpload, '', OPTIONS)
|
||||
context.assert(MODULE, '货柜清关上传返回真实文件事实', clearanceUpload,
|
||||
Boolean(clearanceDocument?.id) && Boolean(clearanceDocument?.fileId)
|
||||
&& clearanceDocument?.documentType === 'DESTINATION_CLEARANCE'
|
||||
&& /^[0-9a-f]{64}$/i.test(String(clearanceDocument?.fileHash || ''))
|
||||
&& !clearanceDocument?.completedAt,
|
||||
`documentId=${id(clearanceDocument?.id) || 'missing'}, version=${clearanceDocument?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!clearanceDocument?.id || !clearanceDocument?.fileId || !Number.isInteger(Number(clearanceDocument?.version))) return
|
||||
const clearanceCompleteResponse = await post(context, `${clearancePath}/complete`, {
|
||||
expectedVersion: Number(clearanceDocument?.version), operationReason: reason,
|
||||
}, 'container-clearance-complete')
|
||||
const clearanceCompleted = context.apiSuccess(MODULE, '货柜点击完成目的港清关',
|
||||
clearanceCompleteResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '货柜清关完成事实可回读', clearanceCompleteResponse,
|
||||
id(clearanceCompleted?.id) === id(clearanceDocument?.id) && Boolean(clearanceCompleted?.completedAt)
|
||||
&& Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1,
|
||||
`documentId=${id(clearanceCompleted?.id) || 'missing'}, completedAt=${clearanceCompleted?.completedAt || 'missing'}`, OPTIONS)
|
||||
async function registerClearanceAction(context, matrix) {
|
||||
const documentsFixture = context.createdResources?.phase3ContainerDocuments
|
||||
const containerId = id(documentsFixture?.containerBookingId)
|
||||
const expectedDocumentId = id(documentsFixture?.documentIds?.DESTINATION_CLEARANCE)
|
||||
context.assert(MODULE, '复用本轮离仓后已完成的目的港清关动作', null,
|
||||
documentsFixture?.marker === context.marker && Boolean(containerId && expectedDocumentId),
|
||||
`containerBookingId=${containerId || 'missing'}, documentId=${expectedDocumentId || 'missing'}`, OPTIONS)
|
||||
if (!containerId || !expectedDocumentId || documentsFixture?.marker !== context.marker) return
|
||||
|
||||
matrix.pages.containerLoading.actions = { clearance: fixture(chain.containerId, 'COMPLETED',
|
||||
`/container-loading/${chain.containerId}/smart-loading`, {
|
||||
documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version),
|
||||
completedAt: clearanceCompleted?.completedAt || null,
|
||||
const response = await context.request('GET', `${CONTAINERS}/${containerId}/clearance`)
|
||||
const completed = context.apiSuccess(MODULE, '回读目的港清关动作验收事实', response, '', OPTIONS)
|
||||
context.assert(MODULE, '目的港清关动作保持完成和版本事实', response,
|
||||
id(completed?.id) === expectedDocumentId && completed?.documentType === 'DESTINATION_CLEARANCE'
|
||||
&& Boolean(completed?.completedAt) && Number.isInteger(Number(completed?.version)),
|
||||
`documentId=${id(completed?.id) || 'missing'}, completedAt=${completed?.completedAt || 'missing'}, version=${completed?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
matrix.pages.containerLoading.actions = { clearance: fixture(containerId, 'COMPLETED',
|
||||
`/container-loading/${containerId}/smart-loading`, {
|
||||
documentId: id(completed?.id), documentVersion: Number(completed?.version),
|
||||
completedAt: completed?.completedAt || null,
|
||||
}) }
|
||||
}
|
||||
|
||||
@@ -464,6 +430,6 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED',
|
||||
`planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS)
|
||||
|
||||
await completeSimpleDocuments(context, completed, matrix)
|
||||
await registerClearanceAction(context, matrix)
|
||||
}
|
||||
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
||||
|
||||
@@ -26,6 +26,11 @@ test('focused matrix execution injects real container lifecycle prerequisites an
|
||||
'phase3-evidence-gate',
|
||||
'phase3-loading-finalize',
|
||||
'phase3-customs',
|
||||
'phase3-port-schedules',
|
||||
'truck-appointments',
|
||||
'service-center-flow',
|
||||
'phase3-outbound-dispatch',
|
||||
'phase3-container-documents',
|
||||
'manual-state-matrix',
|
||||
]
|
||||
assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected)
|
||||
@@ -47,6 +52,11 @@ test('focused splitting and manual groups resolve producers before consumers wit
|
||||
'phase3-loading-finalize',
|
||||
'phase3-order-splitting',
|
||||
'phase3-customs',
|
||||
'phase3-port-schedules',
|
||||
'truck-appointments',
|
||||
'service-center-flow',
|
||||
'phase3-outbound-dispatch',
|
||||
'phase3-container-documents',
|
||||
'manual-state-matrix',
|
||||
]
|
||||
assert.deepEqual(normalizeModuleOrder([
|
||||
|
||||
@@ -10,3 +10,9 @@ test('non-versioned master data stays explicit without making the full gate perm
|
||||
assert.match(source, /非版本化主数据不伪造旧version请求/)
|
||||
assert.match(source, /phase4-overseas-master-data/)
|
||||
})
|
||||
|
||||
test('warehouse import fixture tracks the current version two template and internal code column', () => {
|
||||
assert.match(source, /仓库编码\*', '内部编号', '仓库名称/)
|
||||
assert.match(source, /sheet: '仓库', templateVersion: '2'/)
|
||||
assert.match(source, /xlsxBytes\(fixture\.headers, fixture\.values, fixture\.sheet, fixture\.templateVersion\)/)
|
||||
})
|
||||
|
||||
@@ -31,8 +31,8 @@ function u16(value) { return Uint8Array.from([value & 255, (value >>> 8) & 255])
|
||||
function u32(value) { return Uint8Array.from([value & 255, (value >>> 8) & 255, (value >>> 16) & 255, (value >>> 24) & 255]) }
|
||||
function concat(...parts) { const result = new Uint8Array(parts.reduce((sum, item) => sum + item.length, 0)); let offset = 0; for (const part of parts) { result.set(part, offset); offset += part.length } return result }
|
||||
function xmlEscape(value) { return String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') }
|
||||
function xlsxBytes(headers, values, sheetName) {
|
||||
const rows = [['模板版本', '1'], ['说明', 'Admin HTTP acceptance fixture'], headers, values]
|
||||
function xlsxBytes(headers, values, sheetName, templateVersion = '1') {
|
||||
const rows = [['模板版本', templateVersion], ['说明', 'Admin HTTP acceptance fixture'], headers, values]
|
||||
const cells = rows.map((row, rowIndex) => `<row r="${rowIndex + 1}">${row.map((value, columnIndex) => `<c r="${String.fromCharCode(65 + columnIndex)}${rowIndex + 1}" t="inlineStr"><is><t>${xmlEscape(value)}</t></is></c>`).join('')}</row>`).join('')
|
||||
const files = {
|
||||
'[Content_Types].xml': '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>',
|
||||
@@ -189,7 +189,7 @@ export async function run(context) {
|
||||
['往来单位', '/api/platform/v1/partners/import-template'],
|
||||
]
|
||||
const importFixtures = {
|
||||
仓库: { headers: ['仓库编码*', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库' },
|
||||
仓库: { headers: ['仓库编码*', '内部编号', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, '', `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库', templateVersion: '2' },
|
||||
快递公司: { headers: ['快递公司编码*', '快递公司名称*', '客服电话', '官方网站', '轨迹查询地址模板', '状态*', '备注'], values: [`IME${suffix}`, `导入快递-${suffix}`, '4000000011', 'https://example.com', 'https://example.com/{trackingNo}', 'ACTIVE', context.marker], sheet: '快递公司' },
|
||||
往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '船司标准编码', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入船公司-${suffix}`, 'CARRIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'MATSON', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' },
|
||||
}
|
||||
@@ -199,7 +199,7 @@ export async function run(context) {
|
||||
`bytes=${response.bytes?.length ?? 0}, contentType=${response.contentType || 'missing'}`)
|
||||
if (xlsxOk(response)) {
|
||||
const fixture = importFixtures[name]
|
||||
const fixtureBytes = xlsxBytes(fixture.headers, fixture.values, fixture.sheet)
|
||||
const fixtureBytes = xlsxBytes(fixture.headers, fixture.values, fixture.sheet, fixture.templateVersion)
|
||||
const importPath = `${path.replace('/import-template', '/import')}?tenantId=0`
|
||||
const first = await uploadTemplate(context, importPath, fixtureBytes, `${context.marker}-${name}.xlsx`)
|
||||
const firstData = first.body?.data
|
||||
|
||||
@@ -94,14 +94,17 @@ export async function run(context) {
|
||||
|
||||
for (const documentType of ['DELIVERY_ORDER']) {
|
||||
const response = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/documents/${documentType}`)
|
||||
const data = context.apiSuccess(MODULE, `查询 ${documentType} 单证`, response, `documentType=${documentType}`, OPTIONS)
|
||||
context.assert(MODULE, `${documentType} 单证归属货柜`, response,
|
||||
data == null || id(data.containerId || containerId) === containerId,
|
||||
`containerBookingId=${containerId}`, OPTIONS)
|
||||
context.assert(MODULE, `国内未出仓时 ${documentType} 不生成国外工作台数据`, response,
|
||||
response.http === 200 && String(response?.body?.code) === 'A0404'
|
||||
&& String(response?.body?.message || '').includes('国外工作台货柜不存在'),
|
||||
`HTTP=${response.http}, code=${response?.body?.code ?? 'missing'}, documentType=${documentType}`, OPTIONS)
|
||||
}
|
||||
|
||||
const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`)
|
||||
context.apiSuccess(MODULE, '查询目的港清关资料', clearance, `containerBookingId=${containerId}`, OPTIONS)
|
||||
context.assert(MODULE, '国内未出仓时目的港清关不生成国外工作台数据', clearance,
|
||||
clearance.http === 200 && String(clearance?.body?.code) === 'A0404'
|
||||
&& String(clearance?.body?.message || '').includes('国外工作台货柜不存在'),
|
||||
`HTTP=${clearance.http}, code=${clearance?.body?.code ?? 'missing'}, containerBookingId=${containerId}`, OPTIONS)
|
||||
const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
if (fixture?.finalStatus === 'LOADED') {
|
||||
context.apiSuccess(MODULE, '查询出口报关资料', customs, `loadingPlanId=${planId}, warehouseId=${warehouseId}`, OPTIONS)
|
||||
|
||||
@@ -52,6 +52,12 @@ test('overview regression requires workflowStatus and distinguishes 100 percent
|
||||
assert.doesNotMatch(matrix, /PDA overview `workflowStatus` 缺失[\s\S]*BLOCKED_CONTRACT/)
|
||||
})
|
||||
|
||||
test('pre-departure overview rejects destination workbench documents explicitly', () => {
|
||||
assert.match(overviewAcceptance, /国内未出仓时 \$\{documentType\} 不生成国外工作台数据/)
|
||||
assert.match(overviewAcceptance, /国内未出仓时目的港清关不生成国外工作台数据/)
|
||||
assert.match(overviewAcceptance, /国外工作台货柜不存在/)
|
||||
})
|
||||
|
||||
test('active overview photos download through controlled paths while void history has no download entry', () => {
|
||||
assert.match(overviewOpenApi, /\/api\/platform\/v1\/containers\/\{id\}\/pda\/photos\/\{fileId\}\/download/)
|
||||
assert.match(overviewAcceptance, /\['evidenceId', 'fileId', 'status'\]\.every/)
|
||||
|
||||
@@ -78,6 +78,70 @@ async function request(context, method, path, body, headers = {}) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPdf(context, path, body) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/pdf', Authorization: `Bearer ${context.token}`,
|
||||
'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
return { method: 'POST', path, http: response.status, body: null, raw: null,
|
||||
contentType: response.headers.get('content-type'), byteLength: bytes.length,
|
||||
pdfMagic: String.fromCharCode(...bytes.slice(0, 4)),
|
||||
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method: 'POST', path, http: 0, body: null, raw: String(error), contentType: null,
|
||||
byteLength: 0, pdfMagic: null, requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPalletLabels(context, fixture, loadingPlanId) {
|
||||
if (fixture.destinationType !== 'FBA' || fixture.fulfillmentType === 'ONE_CLICK_DROPSHIP') return null
|
||||
const warehouseId = id(fixture.warehouseId)
|
||||
const base = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(loadingPlanId)}/pallet-labels`
|
||||
const path = `${base}/prepare?warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const preparedResponse = await context.request('POST', path, {})
|
||||
const manifest = context.apiSuccess(MODULE, '生成并读取按 FBA 仓分组的散货托盘码清单', preparedResponse, '', OPTIONS)
|
||||
const labels = (manifest?.groups || []).flatMap((group) => group?.labels || [])
|
||||
const codesValid = labels.length > 0 && labels.every((label) => {
|
||||
const index = Number(label?.palletIndex)
|
||||
return id(label?.containerNo) === id(fixture.containerNo)
|
||||
&& id(label?.destinationWarehouseInternalCode)
|
||||
&& id(label?.palletCode) === `${fixture.containerNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}`
|
||||
})
|
||||
const indexesValid = (manifest?.groups || []).every((group) =>
|
||||
(group?.labels || []).every((label, index) => Number(label?.palletIndex) === index + 1))
|
||||
context.assert(MODULE, '散货托盘码使用柜号-FBA内部编号-仓内连续下标', preparedResponse,
|
||||
id(manifest?.containerNo) === id(fixture.containerNo)
|
||||
&& Number(manifest?.totalCount) === labels.length && codesValid && indexesValid,
|
||||
`containerNo=${manifest?.containerNo || 'missing'}, groups=${manifest?.groups?.length ?? 0}, labels=${labels.length}`,
|
||||
OPTIONS)
|
||||
|
||||
const replayResponse = await context.request('POST', path, {})
|
||||
const replay = context.apiSuccess(MODULE, '重复生成散货托盘码返回稳定清单', replayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '散货托盘码重复生成零漂移', replayResponse,
|
||||
JSON.stringify(replay) === JSON.stringify(manifest), `labels=${labels.length}`, OPTIONS)
|
||||
|
||||
const printPath = `${base}/print?warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const allPdf = await requestPdf(context, printPath, { destinationWarehouseCode: null, count: null })
|
||||
context.addStep(MODULE, '打印全部 FBA 仓散货托盘码 PDF', allPdf,
|
||||
allPdf.http === 200 && allPdf.contentType?.toLowerCase().includes('application/pdf')
|
||||
&& allPdf.pdfMagic === '%PDF' && allPdf.byteLength > 0,
|
||||
`HTTP=${allPdf.http}, type=${allPdf.contentType || 'missing'}, bytes=${allPdf.byteLength}`, OPTIONS)
|
||||
const group = manifest?.groups?.[0]
|
||||
const selectedPdf = await requestPdf(context, printPath,
|
||||
{ destinationWarehouseCode: group?.destinationWarehouseCode, count: 1 })
|
||||
context.addStep(MODULE, '按指定 FBA 仓补打 1 张托盘码 PDF', selectedPdf,
|
||||
Boolean(group?.destinationWarehouseCode) && selectedPdf.http === 200
|
||||
&& selectedPdf.contentType?.toLowerCase().includes('application/pdf')
|
||||
&& selectedPdf.pdfMagic === '%PDF' && selectedPdf.byteLength > 0,
|
||||
`warehouse=${group?.destinationWarehouseCode || 'missing'}, HTTP=${selectedPdf.http}, bytes=${selectedPdf.byteLength}`,
|
||||
OPTIONS)
|
||||
return manifest
|
||||
}
|
||||
function blockResourceIdentity(resource) {
|
||||
return JSON.stringify({
|
||||
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
|
||||
@@ -490,6 +554,7 @@ export async function run(context) {
|
||||
inventoryBlockSettlementVerified: expectedBlocks.length > 0
|
||||
&& releasedOrTransferredBlocks.every((item) => item.verified) && completedInventoryReleased,
|
||||
}
|
||||
const palletLabelManifest = await verifyPalletLabels(context, fixture, loadingPlanId)
|
||||
const producer = {
|
||||
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
|
||||
eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED',
|
||||
@@ -506,6 +571,7 @@ export async function run(context) {
|
||||
cartonMark: fixture.cartonMark || null,
|
||||
inboundCode: fixture.inboundCode || null,
|
||||
sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [],
|
||||
sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [],
|
||||
cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [],
|
||||
skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [],
|
||||
skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [],
|
||||
@@ -513,6 +579,7 @@ export async function run(context) {
|
||||
destinationType: fixture.destinationType || null,
|
||||
destinationWarehouseId: id(fixture.destinationWarehouseId),
|
||||
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
|
||||
palletLabelManifest,
|
||||
containerNo: fixture.containerNo || null,
|
||||
sourceStatus: 'LOADED',
|
||||
verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP',
|
||||
|
||||
@@ -1607,6 +1607,7 @@ export async function run(context) {
|
||||
context.createdResources.phase3Loading = {
|
||||
inboundCode: initial?.summary?.inboundCode || source?.inboundCode,
|
||||
sealedCartonMarks: Array.isArray(source?.sealedCartonMarks) ? source.sealedCartonMarks : [],
|
||||
sealedSourcePalletNos: Array.isArray(source?.sealedSourcePalletNos) ? source.sealedSourcePalletNos : [],
|
||||
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
|
||||
cartonMark: source?.boxCode || source?.cartonMark || null,
|
||||
cartonMarks: stableLoadedCartonMarks(stagedTask?.items),
|
||||
|
||||
@@ -64,6 +64,17 @@ test('PDA opens only after linking a released requirement and uses stable plan i
|
||||
assert.doesNotMatch(source, /push-preload/)
|
||||
})
|
||||
|
||||
test('plan confirmation prepares the remainder initial fee first', () => {
|
||||
const feeIndex = source.indexOf('ensureInitialCalculated(context')
|
||||
const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`")
|
||||
assert.match(source, /import \{ ensureInitialCalculated \} from '\.\/inbound-fees\.mjs'/)
|
||||
assert.ok(feeIndex >= 0 && confirmIndex > feeIndex)
|
||||
assert.match(source, /inboundId: id\(candidate\.inboundId\)/)
|
||||
assert.match(source, /finalStatus: 'APPROVED'/)
|
||||
assert.match(source, /processStatus: 'WAIT_SPLIT'/)
|
||||
assert.match(source, /if \(!initialFee\) return null/)
|
||||
})
|
||||
|
||||
test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => {
|
||||
const claimIndex = source.indexOf("`${PDA}/tasks/${taskId}/claim`")
|
||||
const snapshotIndex = source.indexOf('inventory-snapshot?warehouseId=')
|
||||
|
||||
@@ -9,6 +9,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading'
|
||||
|
||||
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
||||
import { ensureInitialCalculated } from './inbound-fees.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
@@ -242,6 +243,14 @@ async function loadOneFragment(context, state) {
|
||||
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
|
||||
|
||||
const initialFee = await ensureInitialCalculated(context, {
|
||||
tenantId,
|
||||
inboundId: id(candidate.inboundId),
|
||||
finalStatus: 'APPROVED',
|
||||
processStatus: 'WAIT_SPLIT',
|
||||
}, OPTIONS)
|
||||
if (!initialFee) return null
|
||||
|
||||
const createPlan = await request(context, 'GET', `${SMART}/plans/by-booking/${containerId}`)
|
||||
const plan = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 模拟柜自动创建的装柜计划`, createPlan, '', OPTIONS)
|
||||
const planId = id(plan?.id)
|
||||
|
||||
@@ -92,6 +92,7 @@ test('measurement and loading pass destination identity into the LOADED producer
|
||||
assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/)
|
||||
const entry = read('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(entry, /sealedSourcePalletNos: Array\.isArray\(fixture\.sealedSourcePalletNos\)/)
|
||||
assert.match(entry, /orderFulfillmentType/)
|
||||
assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/)
|
||||
assert.match(entry, /inboundFees\.ensureInitialCalculated\(this,/)
|
||||
@@ -117,8 +118,11 @@ test('measurement and loading pass destination identity into the LOADED producer
|
||||
test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => {
|
||||
const entry = read('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(entry, /const hasSecondaryProducer =/)
|
||||
assert.match(entry, /if \(!hasSecondaryProducer\(\)\)/)
|
||||
assert.doesNotMatch(entry, /if \(Number\(readiness\(\)\.distinctContainerCount \|\| 0\) < 4\)/)
|
||||
const start = entry.indexOf('if (pickupRequired && producerKey)')
|
||||
const end = entry.indexOf('} finally {', start)
|
||||
const pickupChain = entry.slice(start, end)
|
||||
@@ -146,6 +150,7 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before
|
||||
assert.match(entry, /overseasReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /awaitingVariants\.length >= 1/)
|
||||
assert.doesNotMatch(entry, /awaitingVariants\.length === 2/)
|
||||
assert.match(entry, /standardFbaReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/)
|
||||
})
|
||||
|
||||
|
||||
@@ -69,6 +69,25 @@ test('records LOADED and clears the reason only after a real LOADED reread', ()
|
||||
assert.equal(result.notCleanedReason, null)
|
||||
})
|
||||
|
||||
test('finalize acceptance covers stable pallet generation and both print modes', () => {
|
||||
const value = source('./phase3-loading-finalize.mjs')
|
||||
assert.match(value, /pallet-labels`/)
|
||||
assert.match(value, /`\$\{base\}\/prepare\?warehouseId=/)
|
||||
assert.match(value, /柜号-FBA内部编号-仓内连续下标/)
|
||||
assert.match(value, /重复生成散货托盘码返回稳定清单/)
|
||||
assert.match(value, /destinationWarehouseCode: null, count: null/)
|
||||
assert.match(value, /按指定 FBA 仓补打 1 张托盘码 PDF/)
|
||||
assert.match(value, /pdfMagic === '%PDF'/)
|
||||
assert.match(value, /palletLabelManifest,/)
|
||||
})
|
||||
|
||||
test('overseas conversion proves source-code preservation and native code generation boundaries', () => {
|
||||
const value = source('./phase4-overseas-pda.mjs')
|
||||
assert.match(value, /头程 FBA 散货托盘码跨区原样用于实体托盘/)
|
||||
assert.match(value, /标准海外仓订单由海外仓生成本地实体托盘码/)
|
||||
assert.match(value, /actualPalletCodes\.every\(\(code\) => code\.startsWith\('PLT-'\)\)/)
|
||||
})
|
||||
|
||||
test('container loading follows push, claim, real pick, completed sequence', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assert.match(value, /const containerDetail = containerRecord\(context\.apiSuccess/)
|
||||
|
||||
@@ -24,8 +24,10 @@ const PHASE5_OPERATOR_CODES = [
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit',
|
||||
]
|
||||
@@ -88,7 +90,7 @@ export async function run(context) {
|
||||
context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse,
|
||||
operatorCodes.has('platform:firstleg:inbound:loading:view')
|
||||
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
|
||||
&& operatorCodes.size === 29
|
||||
&& operatorCodes.size === 31
|
||||
&& PHASE4_READ_CODES.every((code) => operatorCodes.has(code))
|
||||
&& PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code))
|
||||
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
|
||||
|
||||
@@ -26,7 +26,7 @@ test('warehouse operator and tally use the exact phase-four and phase-five permi
|
||||
assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`))
|
||||
assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`))
|
||||
}
|
||||
assert.match(acceptance, /operatorCodes\.size === 29/)
|
||||
assert.match(acceptance, /operatorCodes\.size === 31/)
|
||||
assert.match(acceptance, /tallyCodes\.size === 27/)
|
||||
assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/)
|
||||
assert.match(iamSql, /assert_phase4_warehouse_role_minimum/)
|
||||
@@ -47,8 +47,10 @@ test('warehouse roles include the exact phase-five fulfillment responsibilities'
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit'
|
||||
]) {
|
||||
|
||||
@@ -47,7 +47,7 @@ function context(resources = {}) {
|
||||
if (token === this.overseasToken) return response(path, null, 403, 'A0403')
|
||||
if (path.includes('/overview')) return response(path, { container: {
|
||||
id: '3001', containerNo: 'MSCU1234567', unloadingCompletedAt: '2026-08-24T10:00:00Z',
|
||||
unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-26T09:00:00Z',
|
||||
unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-27T10:00:00Z',
|
||||
actualEmptyReportedAt: null, emptyReportSourceVersion: 7,
|
||||
} })
|
||||
return response(path, { records: [{ container: { id: '3001', containerNo: 'MSCU1234567' } }] })
|
||||
@@ -103,6 +103,13 @@ test('cross-region producers do not repeat the destructive A0 snapshot mutation'
|
||||
assert.match(fixtureCall, /preparePhase3LoadingFixture\('', false\)/)
|
||||
})
|
||||
|
||||
test('tail controls receive their own completed producer matrix before they consume it', async () => {
|
||||
const entry = await import('node:fs/promises').then((fs) => fs.readFile(
|
||||
new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8'))
|
||||
assert.match(entry, /module === 'phase4-tail-order-controls' \|\| module === 'phase4-overseas-pda'/)
|
||||
assert.match(entry, /await this\.ensurePhase4PdaProducerMatrix\(\)/)
|
||||
})
|
||||
|
||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||
for (const token of [
|
||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function run(context) {
|
||||
const estimatedAt = Date.parse(snapshot.estimatedEmptyReportedAt)
|
||||
const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt)
|
||||
&& Number.isFinite(estimatedAt) && completedAt >= uploadedAt
|
||||
&& estimatedAt - uploadedAt === 2 * 24 * 60 * 60 * 1_000
|
||||
&& estimatedAt - completedAt === 72 * 60 * 60 * 1_000
|
||||
context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response,
|
||||
validTimeline && traced(writtenBack.response),
|
||||
evidence(writtenBack.response,
|
||||
|
||||
@@ -33,6 +33,11 @@ test('relabel transfer and discard cover create replay conflict list detail and
|
||||
assert.match(source, /chargeAmount: 0\.01/)
|
||||
assert.match(source, /cartonMark:/)
|
||||
assert.match(source, /fbaCartonMark:/)
|
||||
for (const field of [
|
||||
'operationWarehouseId:', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:',
|
||||
'destinationFbaWarehouseCode:',
|
||||
]) assert.match(source, new RegExp(field))
|
||||
assert.match(source, /query\(`\$\{RELABEL\}\/inventory-candidates`, \{/)
|
||||
for (const token of [
|
||||
'客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409',
|
||||
'客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_PROCESSING',
|
||||
|
||||
@@ -89,13 +89,30 @@ async function verifyRelabel(context, token, order) {
|
||||
`orderId=${id(order.row?.id) || 'missing'}, sku=${cargo?.skuCode || 'missing'}, warehouse=${order.row?.overseasWarehouseId || 'missing'}`)
|
||||
return false
|
||||
}
|
||||
const inventory = await merchant(context, token, 'GET', '客户查询贴标权威库存候选',
|
||||
query(`${RELABEL}/inventory-candidates`, {
|
||||
operationWarehouseId: order.row.overseasWarehouseId,
|
||||
inboundCode: order.row.inboundCode,
|
||||
}))
|
||||
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
|
||||
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
|
||||
&& item?.skuCode === cargo.skuCode
|
||||
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0))
|
||||
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0)
|
||||
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response,
|
||||
Boolean(source && position),
|
||||
`balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS)
|
||||
if (!source || !position) return false
|
||||
const body = {
|
||||
customerOrderNo: order.row.orderNo,
|
||||
overseasWarehouseId: order.row.overseasWarehouseId,
|
||||
operationWarehouseId: order.row.overseasWarehouseId,
|
||||
labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006',
|
||||
chargeAmount: 0.01,
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255),
|
||||
items: [{
|
||||
sourceInventoryBalanceId: id(source.inventoryBalanceId),
|
||||
sourceInventoryPositionId: id(position.positionId),
|
||||
sourceInboundOrderId: id(source.inboundOrderId),
|
||||
skuCode: cargo.skuCode, productImageFileId: null, productImageFileName: null, productImageUrl: null,
|
||||
productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1,
|
||||
shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null,
|
||||
@@ -103,6 +120,7 @@ async function verifyRelabel(context, token, order) {
|
||||
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
|
||||
cartonMark: `CM-${String(context.marker).slice(-32)}`,
|
||||
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`,
|
||||
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
||||
}],
|
||||
}
|
||||
const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body)
|
||||
|
||||
@@ -8,8 +8,12 @@ const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-inbound-operations.mjs'), 'utf8')
|
||||
|
||||
test('module covers the frozen Admin inbound-plan write surface', () => {
|
||||
for (const value of ['/convert', '/pallets/supplement', '/fba-appointments/batch',
|
||||
for (const value of ['/container-preview', '/convert-container', '/pallets/supplement', '/fba-appointments/batch',
|
||||
'/fba-appointments/${encodeURIComponent(appointmentNo)}']) assert.ok(source.includes(value), value)
|
||||
assert.equal(source.includes("`${PLAN_BASE}/convert`"), false)
|
||||
assert.match(source, /snapshotVersion/)
|
||||
assert.match(source, /snapshotHash/)
|
||||
assert.match(source, /'Idempotency-Key'/)
|
||||
assert.match(source, /重复转换返回同一入仓计划/)
|
||||
assert.match(source, /FBA预约同单号异载荷返回A0409/)
|
||||
assert.match(source, /appointmentStatus: 'CANCELLED'/)
|
||||
|
||||
@@ -15,8 +15,8 @@ function query(path, values) {
|
||||
}
|
||||
function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) }
|
||||
|
||||
async function request(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
async function request(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS)
|
||||
return ok(response) ? response.body?.data : null
|
||||
}
|
||||
@@ -40,9 +40,20 @@ async function inboundAdminLifecycle(context) {
|
||||
`orderId=${id(order?.id) || 'missing'}, containerNo=${producer.containerNo}`, OPTIONS)
|
||||
if (!order?.id) return
|
||||
|
||||
const convertBody = { orderId: id(order.id), customerId }
|
||||
const plan = await request(context, 'POST', '尾程入仓单转换为入仓计划', `${PLAN_BASE}/convert`, convertBody)
|
||||
const replay = await request(context, 'POST', '重复转换返回同一入仓计划', `${PLAN_BASE}/convert`, convertBody)
|
||||
const bookingId = id(producer.containerBookingId)
|
||||
const preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, {
|
||||
sourceContainerBookingId: bookingId,
|
||||
}))
|
||||
const convertBody = { sourceContainerBookingId: bookingId,
|
||||
snapshotVersion: Number(preview?.snapshotVersion), snapshotHash: preview?.snapshotHash }
|
||||
const convertHeaders = { 'Idempotency-Key': key(context, 'convert-container') }
|
||||
const plans = await request(context, 'POST', '尾程入仓单转换为入仓计划',
|
||||
`${PLAN_BASE}/convert-container`, convertBody, convertHeaders)
|
||||
const replayedPlans = await request(context, 'POST', '重复转换返回同一入仓计划',
|
||||
`${PLAN_BASE}/convert-container`, convertBody, convertHeaders)
|
||||
const plan = (plans || []).find((item) => (item?.lines || []).some((line) => id(line?.orderId) === id(order.id)))
|
||||
|| plans?.[0]
|
||||
const replay = (replayedPlans || []).find((item) => item?.planNo === plan?.planNo) || replayedPlans?.[0]
|
||||
context.assert(MODULE, '入仓计划转换天然幂等', null,
|
||||
Boolean(plan?.planNo) && replay?.planNo === plan.planNo,
|
||||
`first=${plan?.planNo || 'missing'}, replay=${replay?.planNo || 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -86,7 +86,7 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne
|
||||
test('PDA module covers the container-number receiving contract and excludes outbound domains', () => {
|
||||
for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign',
|
||||
'/current/cartons/resolve', '/current/pallets/resolve',
|
||||
'/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token)
|
||||
'/current/receipts/bulk', '/current/receipts/carton', '/current/receipts/pallet', '/current/complete']) assert.ok(source.includes(token), token)
|
||||
for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) {
|
||||
assert.ok(source.includes(token), token)
|
||||
}
|
||||
@@ -128,6 +128,9 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /rememberGeneratedPlans\(context, plans\)/)
|
||||
assert.match(source, /resolveGeneratedPallet\(context, inbound\)/)
|
||||
assert.match(source, /generatedPalletsByInbound/)
|
||||
assert.match(source, /standardFba = matrix\?\.byDestinationType\?\.FBA\?\.find/)
|
||||
assert.match(source, /item\?\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'/)
|
||||
assert.match(source, /const producers = \[dropship[\s\S]*?standardFba,/)
|
||||
assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/)
|
||||
assert.match(source, /retained as acceptance evidence/)
|
||||
assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/)
|
||||
|
||||
@@ -350,6 +350,24 @@ async function convertReceivingContainers(context, producers) {
|
||||
&& convertedInboundCodes.size === inboundCodes,
|
||||
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
|
||||
if (plans.length !== inboundCodes) return false
|
||||
const actualPalletCodes = plans.flatMap((item) => Array.isArray(item?.pallets) ? item.pallets : [])
|
||||
.map((pallet) => id(pallet?.palletNo)).filter(nonBlank).sort()
|
||||
const expectedPalletCodes = (producer?.palletLabelManifest?.groups || [])
|
||||
.flatMap((group) => group?.labels || []).map((label) => id(label?.palletCode)).filter(nonBlank).sort()
|
||||
if (producer?.destinationType === 'FBA' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') {
|
||||
context.assert(MODULE, `头程 FBA 散货托盘码跨区原样用于实体托盘 ${containerNo}`, convertedResponse,
|
||||
expectedPalletCodes.length > 0
|
||||
&& JSON.stringify(actualPalletCodes) === JSON.stringify(expectedPalletCodes),
|
||||
`source=${expectedPalletCodes.join(',') || 'missing'}, overseas=${actualPalletCodes.join(',') || 'missing'}`,
|
||||
OPTIONS)
|
||||
}
|
||||
if (producer?.destinationType === 'OVERSEAS' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') {
|
||||
context.assert(MODULE, `标准海外仓订单由海外仓生成本地实体托盘码 ${containerNo}`, convertedResponse,
|
||||
expectedPalletCodes.length === 0 && actualPalletCodes.length > 0
|
||||
&& actualPalletCodes.every((code) => code.startsWith('PLT-')),
|
||||
`sourceCount=${expectedPalletCodes.length}, overseas=${actualPalletCodes.join(',') || 'missing'}`,
|
||||
OPTIONS)
|
||||
}
|
||||
rememberGeneratedPlans(context, plans)
|
||||
const replayResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
@@ -564,6 +582,37 @@ async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures
|
||||
return true
|
||||
}
|
||||
|
||||
async function receiveDropship(context, token, producer, inbounds) {
|
||||
const sourcePalletNos = Array.isArray(producer?.sealedSourcePalletNos)
|
||||
? producer.sealedSourcePalletNos.map(id).filter(nonBlank) : []
|
||||
const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0)
|
||||
context.assert(MODULE, '一件代发来源托盘快照已从国内封托完整传递', null,
|
||||
sourcePalletNos.length > 0 && new Set(sourcePalletNos).size === sourcePalletNos.length,
|
||||
`sourcePallets=${sourcePalletNos.length}, pendingPackages=${expectedPending}`, OPTIONS)
|
||||
if (!sourcePalletNos.length) return false
|
||||
let accepted = 0
|
||||
for (const [index, sourcePalletNo] of sourcePalletNos.entries()) {
|
||||
const body = { sourcePalletNo, idempotencyKey: key(context, `dropship-pallet-${index}`) }
|
||||
const first = await pda(context, token, 'POST', `一件代发整托实收${index + 1}`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body)
|
||||
if (!success(first.response)) return false
|
||||
accepted += Number(first.data?.acceptedPackages || 0)
|
||||
const replay = await pda(context, token, 'POST', `一件代发整托${index + 1}同键同载荷重放`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body)
|
||||
context.assert(MODULE, `一件代发整托${index + 1}幂等结果一致`, replay.response,
|
||||
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
||||
await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
|
||||
{ ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict)
|
||||
await expected(context, token, 'POST', `一件代发来源托盘重复收货被拒绝${index + 1}`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
|
||||
{ ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) }, conflict)
|
||||
}
|
||||
context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null,
|
||||
accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS)
|
||||
return accepted === expectedPending
|
||||
}
|
||||
|
||||
async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
|
||||
if (runAuthorizationProbes && context.overseasToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
|
||||
@@ -666,7 +715,8 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
}
|
||||
const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA')
|
||||
const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS')
|
||||
if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
|
||||
const strictDropship = preferredProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
||||
if (!strictDropship && fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
|
||||
const firstFba = fbaInbounds[0]
|
||||
const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA'
|
||||
&& item.inbound.inboundCode === firstFba.inboundCode)
|
||||
@@ -681,10 +731,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
for (const [index, inbound] of fbaInbounds.entries()) {
|
||||
await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index)
|
||||
}
|
||||
} else if (preferredProducer?.destinationType !== 'OVERSEAS') {
|
||||
} else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') {
|
||||
blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
|
||||
}
|
||||
if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) {
|
||||
if (!strictDropship && overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) {
|
||||
const firstOverseas = overseasInbounds[0]
|
||||
const pallet = pallets.get(firstOverseas.inboundCode)
|
||||
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
|
||||
@@ -692,9 +742,32 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
|
||||
}, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/))
|
||||
await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures)
|
||||
} else if (preferredProducer?.destinationType !== 'FBA') {
|
||||
} else if (!strictDropship && preferredProducer?.destinationType !== 'FBA') {
|
||||
blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
|
||||
}
|
||||
if (strictDropship) {
|
||||
const dropshipInbounds = [...fbaInbounds, ...overseasInbounds]
|
||||
const firstInbound = dropshipInbounds[0]
|
||||
const pallet = firstInbound ? pallets.get(firstInbound.inboundCode) : null
|
||||
if (!firstInbound || !pallet) {
|
||||
blockerGap(context, '一件代发整托链', '当前一件代发柜没有带预留实体托盘的入仓码')
|
||||
return
|
||||
}
|
||||
if (firstInbound.destinationType === 'OVERSEAS') {
|
||||
await expected(context, token, 'POST', '一件代发硬拒绝逐箱收货',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
|
||||
cartonMark: cartonFixtures[0]?.cartonMark,
|
||||
palletCode: pallet.palletCode, idempotencyKey: key(context, 'dropship-carton-forbidden'),
|
||||
}, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/))
|
||||
} else {
|
||||
await expected(context, token, 'POST', '一件代发硬拒绝按箱数收货',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
|
||||
inboundCode: firstInbound.inboundCode, palletCode: pallet.palletCode,
|
||||
packageCount: 1, idempotencyKey: key(context, 'dropship-bulk-forbidden'),
|
||||
}, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/))
|
||||
}
|
||||
await receiveDropship(context, token, preferredProducer, dropshipInbounds)
|
||||
}
|
||||
const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current')
|
||||
if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) {
|
||||
blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response)
|
||||
@@ -778,7 +851,11 @@ export async function run(context) {
|
||||
}
|
||||
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
||||
const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]]
|
||||
const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
||||
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
||||
// fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks.
|
||||
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], standardFba,
|
||||
matrix?.byDestinationType?.OVERSEAS?.[0]]
|
||||
.filter((item) => nonBlank(item?.containerNo))
|
||||
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
||||
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', ()
|
||||
'/api/platform/v1/pallets/summary',
|
||||
'/api/platform/v1/warehouse-admin/pallets/summary',
|
||||
'/pallets/summary?page=1&size=20',
|
||||
'CONTAINER_DESTUFFING',
|
||||
'OVERSEAS_CONTAINER',
|
||||
'DESTUFF_PALLET',
|
||||
'expectedPalletCost',
|
||||
'sourceFeeId',
|
||||
|
||||
@@ -283,7 +283,7 @@ async function verifyDestuffCosts(context, completedFacts) {
|
||||
for (const fact of completedFacts) {
|
||||
const businessNo = `DESTUFF-${fact.containerBookingId}`
|
||||
const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', {
|
||||
businessType: 'CONTAINER_DESTUFFING', businessNo,
|
||||
businessType: 'OVERSEAS_CONTAINER', businessNo,
|
||||
})), (response) => success(response) && rows(response.body.data).length > 0, 75)
|
||||
const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : []
|
||||
const allocated = money(sources.reduce((sum, item) => sum + Number(item.amount || 0), 0))
|
||||
|
||||
@@ -33,6 +33,20 @@ test('module covers the public tail-order write surface before PDA receiving', (
|
||||
assert.match(source, /creationMode === 'AUTO'/)
|
||||
assert.match(source, /requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /palletMergeBatchId/)
|
||||
assert.match(source, /feeItemCode/)
|
||||
assert.match(source, /feeItemName/)
|
||||
assert.match(source, /direction: 'RECEIVABLE'/)
|
||||
assert.match(source, /allIds\.length === 1 \|\| nonBlank\(groupRead\.data\?\.order\?\.palletMergeBatchId\)/)
|
||||
assert.match(source, /businessDate:/)
|
||||
assert.match(source, /originalCurrency === 'USD'/)
|
||||
assert.match(source, /currency === 'CNY'/)
|
||||
assert.match(source, /reviewStatus === 'PENDING'/)
|
||||
assert.match(source, /'Idempotency-Key': `\$\{context\.marker\}-tail-fee`/)
|
||||
assert.match(source, /ensureCurrentUsdRate/)
|
||||
assert.match(source, /monthFrom=\$\{month\}&monthTo=\$\{month\}/)
|
||||
assert.match(source, /existing\?\.status === 'ACTIVE'/)
|
||||
assert.match(source, /businessDate: `\$\{feeMonth\}-01`/)
|
||||
assert.doesNotMatch(source, /supplierDescription/)
|
||||
})
|
||||
|
||||
test('receiving warehouse verification reuses public master data and restores the original assignment', () => {
|
||||
|
||||
@@ -63,6 +63,26 @@ async function detail(context, row, name = '读取尾程订单即时详情') {
|
||||
return api(context, 'GET', name,
|
||||
query(`${ORDERS}/${encodeURIComponent(id(row.id))}`, { customerId: id(row.customerId) }))
|
||||
}
|
||||
async function ensureCurrentUsdRate(context) {
|
||||
const month = new Date().toISOString().slice(0, 7)
|
||||
const path = `/api/platform/v1/last-leg/master-data/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}`
|
||||
const listed = await api(context, 'GET', '查询尾程费用业务月汇率', path)
|
||||
const existing = records(listed.data).find((row) => row?.month === month)
|
||||
if (existing?.status === 'ACTIVE' && Number(existing?.usdToCnyAverage) > 0) return month
|
||||
if (existing) {
|
||||
blocker(context, '尾程费用业务月汇率', `业务月${month}汇率存在但不是ACTIVE,拒绝覆盖既有主数据`, listed.response)
|
||||
return null
|
||||
}
|
||||
const created = await api(context, 'POST', '补齐尾程费用业务月汇率',
|
||||
'/api/platform/v1/last-leg/master-data/exchange-rates', {
|
||||
month, usdToCnyAverage: 7.1, remark: `${context.marker} acceptance rate`,
|
||||
})
|
||||
context.assert(MODULE, '尾程费用业务月汇率可用于USD兑CNY', created.response,
|
||||
created.data?.month === month && created.data?.status === 'ACTIVE'
|
||||
&& Number(created.data?.usdToCnyAverage) > 0,
|
||||
`month=${month}, status=${created.data?.status || 'missing'}`, OPTIONS)
|
||||
return created.data?.status === 'ACTIVE' ? month : null
|
||||
}
|
||||
async function verifyReceivingWarehouseChange(context, producer, details) {
|
||||
const bookingId = id(producer.containerBookingId)
|
||||
if (!bookingId) {
|
||||
@@ -163,22 +183,32 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
||||
const groupRead = await detail(context, representative, '回查批量合托关系')
|
||||
context.assert(MODULE, '批量合托关系包含本轮订单', groupRead.response,
|
||||
groupRead.data?.order?.palletMergeFlag === 'REQUIRED'
|
||||
&& nonBlank(groupRead.data?.order?.palletMergeBatchId), trace(groupRead.response), OPTIONS)
|
||||
&& (allIds.length === 1 || nonBlank(groupRead.data?.order?.palletMergeBatchId)),
|
||||
`orders=${allIds.length}, batchId=${groupRead.data?.order?.palletMergeBatchId || 'single-order'}; ${trace(groupRead.response)}`,
|
||||
OPTIONS)
|
||||
const batchCleared = await api(context, 'PUT', '批量取消PDA合托分组', batchMergePath,
|
||||
{ orderIds: allIds, palletMergeRequired: false })
|
||||
context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS)
|
||||
|
||||
const feeMonth = await ensureCurrentUsdRate(context)
|
||||
if (!feeMonth) return false
|
||||
const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128)
|
||||
const fee = await api(context, 'POST', '新增本轮尾程订单费用',
|
||||
query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }),
|
||||
{ feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker })
|
||||
{ feeItemCode: feeItem.slice(0, 64), feeItemName: feeItem, direction: 'RECEIVABLE',
|
||||
currency: 'USD', amount: 0.01, businessDate: `${feeMonth}-01` },
|
||||
{ 'Idempotency-Key': `${context.marker}-tail-fee`.slice(0, 128) })
|
||||
context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response,
|
||||
id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem
|
||||
&& fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW',
|
||||
id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemName === feeItem
|
||||
&& fee.data?.originalCurrency === 'USD' && Number(fee.data?.originalAmount) === 0.01
|
||||
&& fee.data?.currency === 'CNY' && Number(fee.data?.amount) > 0
|
||||
&& fee.data?.reviewStatus === 'PENDING',
|
||||
trace(fee.response), OPTIONS)
|
||||
const feeRead = await detail(context, representative, '回查尾程订单费用明细')
|
||||
context.assert(MODULE, '尾程费用详情可追溯', feeRead.response,
|
||||
feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS)
|
||||
feeRead.data?.fees?.some((row) => row?.feeItemName === feeItem
|
||||
&& row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01
|
||||
&& row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS)
|
||||
|
||||
const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, {
|
||||
customerId: id(representative.customerId), containerNo: producer.containerNo,
|
||||
|
||||
@@ -43,7 +43,7 @@ test('phase-five module proves the client delivery create, list and nested detai
|
||||
assert.match(source, /id\(profile\?\.id\) === customerId/)
|
||||
assert.match(source, /客户创建派送并原子转出库单/)
|
||||
assert.match(source, /deliveryMethod: 'TRUCK_PALLET'/)
|
||||
assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10/)
|
||||
assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',\s*currency: 'USD', amount: 10/)
|
||||
assert.match(source, /客户分页回查自身派送单/)
|
||||
assert.match(source, /客户读取自身派送详情/)
|
||||
assert.match(source, /merchantDetail\.data\?\.order\?\.deliveryNo/)
|
||||
|
||||
@@ -179,7 +179,8 @@ export async function run(context) {
|
||||
vehicleType: 'TRUCK', vehicleSize: 'MEDIUM', driverName: updateBody.driverName,
|
||||
driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate,
|
||||
appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',
|
||||
currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
|
||||
}
|
||||
const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单',
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
|
||||
@@ -44,6 +44,8 @@ test('four real command payloads mutate contract fields and preserve remarks and
|
||||
const forbidden = index < 2 ? 'remark' : 'reason'
|
||||
assert.equal(Object.hasOwn(body, forbidden), false, `${actionPath} must not send ${forbidden}`)
|
||||
const key = headers['Idempotency-Key']
|
||||
assert.match(key, /^[A-Za-z0-9:_-]+$/)
|
||||
assert.ok(key.endsWith(`terminal-${actionPath}`))
|
||||
const saved = commands.get(key)
|
||||
if (saved) {
|
||||
if (JSON.stringify(saved.body) === JSON.stringify(body)) { replays++; return response(saved.state) }
|
||||
@@ -98,6 +100,8 @@ test('all four commands cover idempotent replay hash conflict and lifecycle sema
|
||||
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
|
||||
]) assert.ok(source.includes(token), `missing ${token}`)
|
||||
assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/)
|
||||
assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//)
|
||||
assert.doesNotMatch(source, /terminal-\$\{action\}/)
|
||||
})
|
||||
|
||||
test('target is strictly current-marker pickup and completed-PDA container', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ function fixture(context) {
|
||||
return null
|
||||
}
|
||||
async function command(context, target, action, path, body) {
|
||||
const key = `${context.marker}-terminal-${action}`.slice(0, 128)
|
||||
const key = `${context.marker}-terminal-${path}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 128)
|
||||
const first = await api(context, 'POST', `目的港码头拖车${action}`, `${BASE}/${target.containerBookingId}/${path}`,
|
||||
body, { 'Idempotency-Key': key })
|
||||
if (!success(first.response)) return first
|
||||
|
||||
@@ -806,7 +806,9 @@ Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{group
|
||||
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
|
||||
Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验'
|
||||
Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属'
|
||||
Assert-Contains $overseasFinanceOpenApi 'OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE' '海外五类费用 OpenAPI'
|
||||
foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE')) {
|
||||
Assert-Contains $overseasFinanceOpenApi $businessType "海外费用 OpenAPI: $businessType"
|
||||
}
|
||||
Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约'
|
||||
Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
|
||||
Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型'
|
||||
|
||||
@@ -53,12 +53,17 @@ foreach ($target in $targets) {
|
||||
}
|
||||
|
||||
if (-not $SkipPda) {
|
||||
$pdaDirectory = Join-Path $repoRoot 'apps/pda-uniapp'
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $pdaDirectory 'tests'))) {
|
||||
Write-Error "PDA tests directory not found: $pdaDirectory"; exit 2
|
||||
$pdaTargets = @(
|
||||
@{ Name = 'Domestic PDA'; Directory = Join-Path $repoRoot 'apps/pda-uniapp' },
|
||||
@{ Name = 'Overseas PDA'; Directory = Join-Path $repoRoot 'apps/pda-overseas-uniapp' }
|
||||
)
|
||||
foreach ($pdaTarget in $pdaTargets) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $pdaTarget.Directory 'tests'))) {
|
||||
Write-Error "$($pdaTarget.Name) tests directory not found: $($pdaTarget.Directory)"; exit 2
|
||||
}
|
||||
Write-Host "[$($pdaTarget.Name)]"
|
||||
Invoke-PdaTests $pdaTarget.Directory
|
||||
}
|
||||
Write-Host '[PDA]'
|
||||
Invoke-PdaTests $pdaDirectory
|
||||
}
|
||||
|
||||
Write-Host 'Frontend regression completed.'
|
||||
|
||||
@@ -10,9 +10,12 @@ const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.
|
||||
const frontendScript = readFileSync(resolve(scriptsDir, 'frontend-full-regression.ps1'), 'utf8')
|
||||
const adminAcceptance = readFileSync(resolve(scriptsDir, 'admin-test-env-acceptance.mjs'), 'utf8')
|
||||
|
||||
test('frontend full regression includes domestic PDA node tests by default', () => {
|
||||
test('frontend full regression includes domestic and overseas PDA node tests by default', () => {
|
||||
assert.match(frontendScript, /\[switch\]\$SkipPda/)
|
||||
assert.match(frontendScript, /apps\/pda-uniapp/)
|
||||
assert.match(frontendScript, /apps\/pda-overseas-uniapp/)
|
||||
assert.match(frontendScript, /Domestic PDA/)
|
||||
assert.match(frontendScript, /Overseas PDA/)
|
||||
assert.match(frontendScript, /node --test/)
|
||||
assert.match(frontendScript, /tests\/\*\.test\.mjs/)
|
||||
})
|
||||
|
||||
@@ -2,92 +2,128 @@ param(
|
||||
[switch]$DryRun,
|
||||
[Alias('Host')][string]$TargetHost = '127.0.0.1',
|
||||
[int]$Port = 3306,
|
||||
[string]$InputPath,
|
||||
[string]$MysqlExe = 'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe',
|
||||
[string]$User = 'root'
|
||||
[string]$User = 'root',
|
||||
[ValidateRange(1, 10)][int]$Rounds = 2
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
||||
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
||||
$ExpectedSha256 = '0EE13427AB60CECCEB42985CF1E78A05645C18CD9BF2886110C0AFEAF3CA7B9B'
|
||||
$ExpectedLines = 15674
|
||||
$ReportRoot = Join-Path $Workspace '.codex-tmp\local-db-full-acceptance'
|
||||
$DomesticSql = Join-Path $Repo 'deploy\sql\头程数据库统一增量-MySQL8.sql'
|
||||
$OverseasSql = Join-Path $Repo 'deploy\sql\海外区域统一增量-MySQL8.sql'
|
||||
$Schemas = @(
|
||||
'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse',
|
||||
'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge',
|
||||
'baiyue_finance', 'baiyue_lastleg', 'baiyue_lastlag_warehouse',
|
||||
'baiyue_event_bridge_overseas'
|
||||
)
|
||||
|
||||
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
||||
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
|
||||
exit 2
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($InputPath)) {
|
||||
$InputPath = (Get-ChildItem (Join-Path $Repo 'deploy\sql') -Filter '*MySQL8.sql' -File |
|
||||
Where-Object { $_.Length -gt 100000 } | Select-Object -First 1).FullName
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $InputPath)) {
|
||||
[Console]::Error.WriteLine("InputPath does not exist: $InputPath")
|
||||
if (-not (Test-Path -LiteralPath $MysqlExe)) {
|
||||
[Console]::Error.WriteLine("mysql executable does not exist: $MysqlExe")
|
||||
exit 2
|
||||
}
|
||||
$actual = ((Get-FileHash -Algorithm SHA256 -LiteralPath $InputPath).Hash).Trim().ToUpperInvariant()
|
||||
$lines = [System.IO.File]::ReadAllLines($InputPath).Length
|
||||
if ($actual -ne $ExpectedSha256 -or $lines -ne $ExpectedLines) {
|
||||
[Console]::Error.WriteLine("Unified SQL snapshot mismatch: $actual/$lines")
|
||||
exit 2
|
||||
}
|
||||
$sqlText = [System.IO.File]::ReadAllText($InputPath)
|
||||
$commentGateTokens = @(
|
||||
'CREATE PROCEDURE queue_column_comment',
|
||||
'CREATE PROCEDURE apply_one_table_comments',
|
||||
'CALL apply_queued_comments();',
|
||||
'字段注释批量升级改变了结构签名',
|
||||
'存在缺失中文注释的业务表',
|
||||
'存在缺失中文注释的业务字段'
|
||||
)
|
||||
foreach ($token in $commentGateTokens) {
|
||||
if (-not $sqlText.Contains($token)) {
|
||||
[Console]::Error.WriteLine("Unified SQL comment acceptance marker missing: $token")
|
||||
foreach ($sqlFile in @($DomesticSql, $OverseasSql)) {
|
||||
if (-not (Test-Path -LiteralPath $sqlFile)) {
|
||||
[Console]::Error.WriteLine("Unified SQL does not exist: $sqlFile")
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
if ([regex]::Matches($sqlText, "SELECT 'PASS' AS migration_status").Count -ne 1) {
|
||||
[Console]::Error.WriteLine('Unified SQL must contain exactly one final PASS statement')
|
||||
|
||||
$DomesticText = [IO.File]::ReadAllText($DomesticSql)
|
||||
$OverseasText = [IO.File]::ReadAllText($OverseasSql)
|
||||
if (-not $DomesticText.Contains("SELECT 'PASS' AS migration_status")) {
|
||||
[Console]::Error.WriteLine('Domestic unified SQL is missing its final PASS statement.')
|
||||
exit 2
|
||||
}
|
||||
foreach ($marker in @(
|
||||
'-- BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG',
|
||||
'-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE',
|
||||
"SELECT 'PASS' AS migration_status"
|
||||
)) {
|
||||
if (-not $OverseasText.Contains($marker)) {
|
||||
[Console]::Error.WriteLine("Overseas unified SQL is missing marker: $marker")
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
$sequence = @(
|
||||
'empty: base.sql', 'replay: base.sql + input.sql + input.sql',
|
||||
'smart-old: fixture-smart-old.sql', 'inbound-old: fixture-inbound-old.sql',
|
||||
'waitsplit-iam: fixture-test-shape.sql', 'overseas-old: fixture-overseas-old.sql',
|
||||
'overseas-missing-objects: fixture-overseas-missing-objects.sql',
|
||||
'interrupted-recovery: fixture-interrupted.sql', 'final-structure: verify-final.sql')
|
||||
Write-Output "SQL SHA-256: $actual / lines: $lines"
|
||||
Write-Output 'Comment gate: queued validation, per-table ALTER, signature verification, nine-schema coverage, unique PASS'
|
||||
$DomesticHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $DomesticSql).Hash
|
||||
$OverseasHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $OverseasSql).Hash
|
||||
Write-Output "Target: $TargetHost`:$Port"
|
||||
Write-Output 'Fixture order:'
|
||||
$sequence | ForEach-Object { Write-Output " $_" }
|
||||
if ($DryRun) { Write-Output 'DRY-RUN PASS (mysql not started; no database writes)'; exit 0 }
|
||||
Write-Output "Rounds: $Rounds"
|
||||
Write-Output "Domestic SQL: $DomesticHash / $([IO.File]::ReadAllLines($DomesticSql).Length) lines"
|
||||
Write-Output "Overseas SQL: $OverseasHash / $([IO.File]::ReadAllLines($OverseasSql).Length) lines"
|
||||
Write-Output "Reset scope: $($Schemas -join ', ')"
|
||||
Write-Output 'Execution order per round: clear exact schemas -> domestic unified SQL -> overseas unified SQL -> final PASS and schema checks'
|
||||
if ($DryRun) {
|
||||
Write-Output 'DRY-RUN PASS (mysql not contacted; no database writes)'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$acceptanceInput = Join-Path $Acceptance 'input.sql'
|
||||
Copy-Item -LiteralPath $InputPath -Destination $acceptanceInput -Force
|
||||
$stagedHash = ((Get-FileHash -Algorithm SHA256 -LiteralPath $acceptanceInput).Hash).Trim().ToUpperInvariant()
|
||||
if ($stagedHash -ne $actual) {
|
||||
[Console]::Error.WriteLine("Failed to stage the validated unified SQL snapshot: $stagedHash")
|
||||
New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null
|
||||
$StagedDomestic = Join-Path $ReportRoot 'domestic.sql'
|
||||
$StagedOverseas = Join-Path $ReportRoot 'overseas.sql'
|
||||
Copy-Item -LiteralPath $DomesticSql -Destination $StagedDomestic -Force
|
||||
Copy-Item -LiteralPath $OverseasSql -Destination $StagedOverseas -Force
|
||||
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $StagedDomestic).Hash -ne $DomesticHash -or
|
||||
(Get-FileHash -Algorithm SHA256 -LiteralPath $StagedOverseas).Hash -ne $OverseasHash) {
|
||||
[Console]::Error.WriteLine('Failed to stage byte-identical SQL files under ASCII filenames.')
|
||||
exit 2
|
||||
}
|
||||
|
||||
$run = 0
|
||||
function Invoke-Stage([string]$Name, [string]$File) {
|
||||
$script = Join-Path $Acceptance $File
|
||||
$log = Join-Path $Acceptance ("acceptance-run-{0:D2}-{1}.log" -f $script:run, $Name)
|
||||
$script:run++
|
||||
if (-not (Test-Path -LiteralPath $script)) { [Console]::Error.WriteLine("Script does not exist: $script"); return 2 }
|
||||
& $MysqlExe '--protocol=TCP' "--host=$TargetHost" "--port=$Port" "--user=$User" '--database=mysql' '--batch' ("--execute=source " + $script.Replace('\','/')) *> $log
|
||||
return $LASTEXITCODE
|
||||
$MysqlCommon = @(
|
||||
'--protocol=TCP', "--host=$TargetHost", "--port=$Port", "--user=$User",
|
||||
'--default-character-set=utf8mb4', '--batch'
|
||||
)
|
||||
& $MysqlExe @MysqlCommon '--skip-column-names' '--execute=SELECT VERSION(), @@hostname, @@port;' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
[Console]::Error.WriteLine('Cannot connect to the local MySQL target. Configure authentication outside this script, for example with MYSQL_PWD for the current process.')
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$calls = @(
|
||||
@('empty','base.sql'), @('replay-reset','base.sql'), @('replay-1','input.sql'), @('replay-2','input.sql'),
|
||||
@('smart-old','fixture-smart-old.sql'), @('inbound-old','fixture-inbound-old.sql'), @('waitsplit-iam','fixture-test-shape.sql'),
|
||||
@('overseas-old','fixture-overseas-old.sql'), @('overseas-missing','fixture-overseas-missing-objects.sql'),
|
||||
@('interrupted','fixture-interrupted.sql'), @('final-verify','verify-final.sql'))
|
||||
foreach ($call in $calls) { $code = Invoke-Stage $call[0] $call[1]; if ($code -ne 0) { exit $code } }
|
||||
Write-Output 'FULL LOCAL ACCEPTANCE PASS'
|
||||
$DropSql = ($Schemas | ForEach-Object { "DROP DATABASE IF EXISTS ``$_```;" }) -join ' '
|
||||
for ($round = 1; $round -le $Rounds; $round++) {
|
||||
$RoundRoot = Join-Path $ReportRoot ("round-{0}" -f $round)
|
||||
New-Item -ItemType Directory -Path $RoundRoot -Force | Out-Null
|
||||
$Stopwatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
& $MysqlExe @MysqlCommon "--execute=$DropSql" *> (Join-Path $RoundRoot '00-clear.log')
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
[Console]::Error.WriteLine("Round $round failed while clearing the exact project schemas.")
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$DomesticLog = Join-Path $RoundRoot '10-domestic.log'
|
||||
$DomesticSource = $StagedDomestic.Replace('\', '/')
|
||||
& $MysqlExe @MysqlCommon '--database=mysql' "--execute=source $DomesticSource" *> $DomesticLog
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Select-String -LiteralPath $DomesticLog -Pattern '^PASS\s+统一增量全部执行完成' -Quiet)) {
|
||||
[Console]::Error.WriteLine("Round $round domestic unified SQL failed. See $DomesticLog")
|
||||
exit $(if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 2 })
|
||||
}
|
||||
|
||||
$OverseasLog = Join-Path $RoundRoot '20-overseas.log'
|
||||
$OverseasSource = $StagedOverseas.Replace('\', '/')
|
||||
& $MysqlExe @MysqlCommon '--database=mysql' "--execute=source $OverseasSource" *> $OverseasLog
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Select-String -LiteralPath $OverseasLog -Pattern '^PASS\s+海外财务P0费用来源字段已就绪$' -Quiet)) {
|
||||
[Console]::Error.WriteLine("Round $round overseas unified SQL failed. See $OverseasLog")
|
||||
exit $(if ($LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 2 })
|
||||
}
|
||||
|
||||
$SchemaList = "'" + ($Schemas -join "','") + "'"
|
||||
$SchemaCount = (& $MysqlExe @MysqlCommon '--skip-column-names' "--execute=SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name IN ($SchemaList);").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or [int]$SchemaCount -ne $Schemas.Count) {
|
||||
[Console]::Error.WriteLine("Round $round schema verification failed: $SchemaCount/$($Schemas.Count)")
|
||||
exit 2
|
||||
}
|
||||
|
||||
$Stopwatch.Stop()
|
||||
Write-Output "ROUND $round PASS / $SchemaCount schemas / $([Math]::Round($Stopwatch.Elapsed.TotalSeconds, 1)) seconds"
|
||||
}
|
||||
|
||||
Write-Output 'FULL LOCAL TWO-REGION DATABASE ACCEPTANCE PASS'
|
||||
exit 0
|
||||
|
||||
@@ -159,7 +159,8 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV
|
||||
assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(source, /includes\('-P4-FBA-STANDARD:'\)/)
|
||||
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(source, /runUntilPickupReady\(this\)/)
|
||||
const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix'))
|
||||
|
||||
@@ -17,13 +17,17 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
|
||||
- `--expected-host` 必须与配置完全相同且以 `test-` 或 `test.` 开头,禁止生产环境。
|
||||
- `--stage preflight` 登录并查询目录、托盘和编号策略,不创建业务数据。
|
||||
- `--stage create --limit 1` 按源实际箱数、重量、体积尝试创建一票草稿,不伪造图片、尺寸或报关字段,不提交订单。按用户确认规则,源表未注明木制品时 `woodenProduct=false`;其他缺失必填项保留服务端错误,不保证所有源行能创建。
|
||||
- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只有明确接受下述模拟规则时才能使用。
|
||||
- `--limit` 是本次新增尝试数,默认 1,串行执行。前三票连续失败且没有成功时停止扩大写入。
|
||||
- `--stage pending-review --allow-test-placeholders true --limit 1` 创建订单并由客户提交,回查客户状态 `SUBMITTED` 和统一状态 `PENDING_REVIEW` 后停止;不审核、不生成入库投影,不调用箱码、测量、称重、托盘或库位接口。这是源订单批量造数的默认推荐阶段。试价返回“无匹配报价”或“未达到最低重量阶梯”时只记录 `pricingWarning`,仍创建并提交到待审核,交由人工审核处理。
|
||||
- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只用于少量完整链验收,不用于大批量造数。
|
||||
- `--limit` 是本次新增尝试数,默认 1;实际处理方式由 `--concurrency` 决定。前三票连续失败且没有成功时停止扩大写入。
|
||||
- `--concurrency` 是单进程并发票数,默认 1、最大 10。所有并发票共享一个带串行落盘队列的 `state.json` 和一个批次锁,不得再启动多个进程。测试环境实测 3 路可稳定运行;提高并发前必须重新小批压测,出现服务端 `S0500` 时降低并发,不能用失败率换速度。
|
||||
|
||||
## 源数据与模拟规则
|
||||
|
||||
同入仓码多行按实际箱数/毛重/体积求和,分柜明细还须与唯一预报箱数相符;同码客户、渠道、目的仓或报关方式冲突则阻断。缺失或零实际值不以预报替换。无有效入仓码的行排除,源文件保持原样。
|
||||
|
||||
订单表持久精度为毛重 3 位小数、体积 4 位小数。拆分行先按源实际值求和,再统一按该精度四舍五入后创建和回查;不逐行先取整,避免分摊尾差导致总量漂移。
|
||||
|
||||
报关方式“买单改退税”取最终退税,“退税改买单”取最终买单;空白或未识别文本阻断,不默认买单。
|
||||
|
||||
试价计费方式取“计费数据”后的第一个“单位”列:KGS/KG 映射 KG,CBM 保留。包税按服务端契约固定 KG;不包税单位缺失或分柜行冲突则阻断。
|
||||
@@ -38,20 +42,24 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
|
||||
|
||||
源体积可用三位小数表达时,优先以 0.001 m³ 分组,使各明细体积重和汇总体积重避免逐行向上取整差异;否则按四位精度分组。提交前同时检查明细体积重合计与批次一致,不一致记录服务端取整阻断,不提交审核或伪造总量。
|
||||
|
||||
照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。
|
||||
产品照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。同一批次只上传一次产品占位图并在订单中复用;箱级测量图和每托称重图仍逐条上传作为正式流程要求的证据。客户箱规每次进程启动只查询一次。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。
|
||||
|
||||
托盘只允许 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。
|
||||
测量草稿是带版本的幂等更新。仅当该接口返回服务端 `S0500` 时,脚本最多重读两次最新测量版本后重试,并把每次 requestId/traceId 记入订单检查点;业务校验错误、其他接口错误和第三次失败均原样终止该票,不无限重试。
|
||||
|
||||
只有 `wait-split` 阶段会查询并复用 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。`pending-review` 完全不查询或占用托盘、库位。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。
|
||||
|
||||
## 报告与重跑
|
||||
|
||||
必须保留批次目录:`preflight.json` 为全部解析/主数据匹配结果;`state.json` 为源文件哈希、批次身份、源行号到新订单/入仓码的对应、每次写入前后的检查点、错误码和 requestId/traceId。该目录包含业务数据,仅在本机仓库外保存,不提交或公开分享。原始 Excel 自身是完整源数据依据。
|
||||
|
||||
同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`limit` 每次从尚未尝试的票继续;不是完整失败恢复器。特别注意:`create` 成功的草稿不会因切换 `wait-split` 自动补齐和推进;需单独选择尚未尝试票做完整链试跑。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。
|
||||
同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`pending-review` 仅安全恢复停在试价阶段、尚未创建订单的 `A0404/A0422`,旧错误保留在 `priorAttempts`。载荷规则发生变化后必须使用新的 batch-id 和输出目录,不能用旧创建幂等键发送不同载荷。`limit` 每次从尚未尝试或上述可恢复票继续;不是通用失败恢复器。特别注意:不同阶段不能用同一批状态自动推进已有订单;需使用全新批次选择尚未尝试票。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。任一并发票返回 `A0401` 时,本批最多完成当前并发组便立即熔断,防止令牌失效污染后续订单。批次锁记录进程 PID;异常终止后,下次启动仅在该 PID 已不存在时清理精确的陈旧锁。
|
||||
|
||||
源文件、客户、网关或批次 ID 改变时拒绝复用目录。并发进程通过 `run.lock` 拒绝;异常终止残留锁,人工确认旧进程已结束后才可移除精确锁文件。
|
||||
|
||||
用户清库并重新初始化客户、报价、仓库和原有托盘后,指定**新的 batch-id 和全新输出目录**重新运行同一脚本。不能复用旧状态,否则已尝试票会跳过。清库不由本脚本执行。
|
||||
|
||||
清库后的批量待审核运行固定使用 `--stage pending-review --allow-test-placeholders true --concurrency 10 --limit 10000`。先确认 IT测试客户、义乌仓、可选渠道与目的仓主档已经初始化;报价缺失不阻断待审核创建。运行完成以 `preflight.matched = state.orders`、全部检查点无 `STARTED`、成功项均为 `PENDING_REVIEW` 为完成口径。
|
||||
|
||||
## 本地检查
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Explicitly authorized non-production HTTP import. Never resets a database.
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, readFile, writeFile, rename, open } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
|
||||
@@ -45,7 +45,7 @@ export function normalizeRows(rows) {
|
||||
for (const name of ['实际件数', '实际毛重', '实际立方']) {
|
||||
if (entries.some(({ row }) => !positive(get(row, name)))) problems.push(`MISSING_${name}`)
|
||||
}
|
||||
const sum = (name) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0))
|
||||
const sum = (name, digits = 6) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0), digits)
|
||||
const packageCount = sum('实际件数')
|
||||
if (!Number.isInteger(packageCount) || packageCount < 1) problems.push('INVALID_PACKAGE_COUNT')
|
||||
if (entries.length > 1) {
|
||||
@@ -68,7 +68,10 @@ export function normalizeRows(rows) {
|
||||
sourceCustomer: text(get(first, '客户')), sourceChannel, channelName: channelName(sourceChannel),
|
||||
destination, destinationType, pricingTaxMode, taxMode, chargeMode,
|
||||
fulfillmentType: destination.includes('一件代发') ? 'ONE_CLICK_DROPSHIP' : 'STANDARD',
|
||||
packageCount, weightKg: sum('实际毛重'), volumeCbm: sum('实际立方'),
|
||||
// Match the persisted order schema: kg has 3 decimals and CBM has 4.
|
||||
// Split source rows can contain longer allocation decimals whose exact sum
|
||||
// cannot be represented by the order table.
|
||||
packageCount, weightKg: sum('实际毛重', 3), volumeCbm: sum('实际立方', 4),
|
||||
cargoName: distinct('品名').filter(Boolean).join(' / '),
|
||||
sku: distinct('SKU').filter(Boolean).join('\n'), shipmentId: distinct('FBA货件编号').filter(Boolean).join('\n'),
|
||||
referenceId: distinct('跟踪编号').filter(Boolean).join('\n'), problems }
|
||||
@@ -199,7 +202,7 @@ export function requireSuccess(response) {
|
||||
|
||||
async function main() {
|
||||
const args = new Map()
|
||||
const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host'])
|
||||
const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'concurrency', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host'])
|
||||
for (let i = 2; i < process.argv.length; i += 2) {
|
||||
const name = process.argv[i].replace(/^--/, ''), value = process.argv[i + 1]
|
||||
if (!allowed.has(name) || !value || value.startsWith('--')) throw Error(`无效参数:${name}`)
|
||||
@@ -207,10 +210,14 @@ async function main() {
|
||||
}
|
||||
for (const key of ['input', 'python', 'output-dir', 'batch-id', 'expected-host']) if (!args.get(key)) throw Error(`缺少 --${key}`)
|
||||
const stage = args.get('stage') || 'preflight'
|
||||
if (!['preflight', 'create', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/wait-split')
|
||||
if (stage === 'wait-split' && args.get('allow-test-placeholders') !== 'true') throw Error('此源表缺少必填字段,模拟测量须显式允许测试占位')
|
||||
if (!['preflight', 'create', 'pending-review', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/pending-review/wait-split')
|
||||
if (['pending-review', 'wait-split'].includes(stage) && args.get('allow-test-placeholders') !== 'true') {
|
||||
throw Error('此源表缺少提交必填字段,须显式允许测试占位')
|
||||
}
|
||||
const limit = Number(args.get('limit') || 1)
|
||||
if (!Number.isInteger(limit) || limit < 1) throw Error('limit 必须为正整数')
|
||||
const concurrency = Number(args.get('concurrency') || 1)
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) throw Error('concurrency 必须是1到10的整数')
|
||||
const options = validateExecutionOptions(parseArguments(['--modules', 'orders,inbound-measurement', '--quiet', 'true']))
|
||||
if (new URL(options.gateway).hostname !== args.get('expected-host') || !/^test[-.]/.test(args.get('expected-host'))) throw Error('只允许明确匹配的测试域名')
|
||||
const input = resolve(args.get('input'))
|
||||
@@ -229,16 +236,22 @@ async function main() {
|
||||
try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
if (state && JSON.stringify(state.identity) !== JSON.stringify(identity)) throw Error('批次/源文件/目标发生变化,请使用独立输出目录')
|
||||
state ||= { identity, orders: {}, startedAt: new Date().toISOString() }
|
||||
const lock = await open(resolve(directory, 'run.lock'), 'wx')
|
||||
const save = async () => {
|
||||
await writeFile(`${statePath}.tmp`, JSON.stringify(state, null, 2), 'utf8')
|
||||
const lockPath = resolve(directory, 'run.lock')
|
||||
const lock = await acquireRunLock(lockPath)
|
||||
let saveQueue = Promise.resolve()
|
||||
const save = () => {
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
const temporaryPath = `${statePath}.${process.pid}.tmp`
|
||||
await writeFile(temporaryPath, JSON.stringify(state, null, 2), 'utf8')
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await rename(`${statePath}.tmp`, statePath); break }
|
||||
try { await rename(temporaryPath, statePath); break }
|
||||
catch (error) {
|
||||
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
})
|
||||
return saveQueue
|
||||
}
|
||||
const ctx = new AcceptanceRun(options)
|
||||
const api = async (method, path, body, token = ctx.token, headers = {}) => requireSuccess(await ctx.request(method, path, body, token, headers))
|
||||
@@ -264,14 +277,35 @@ async function main() {
|
||||
const channels = await api('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, undefined, merchant)
|
||||
const catalog = { receiving, channels, destinations: [...await list('FBA'), ...await list('OVERSEAS')] }
|
||||
const pallets = []
|
||||
for (const code of PALLETS) {
|
||||
const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`)
|
||||
const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id))
|
||||
if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`)
|
||||
pallets.push(found[0])
|
||||
if (stage === 'wait-split') {
|
||||
for (const code of PALLETS) {
|
||||
const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`)
|
||||
const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id))
|
||||
if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`)
|
||||
pallets.push(found[0])
|
||||
}
|
||||
}
|
||||
const policy = await api('GET', '/api/merchant/v1/orders/first-leg/inbound-code-policy', undefined, merchant)
|
||||
if (policy.mode !== 'AUTO') throw Error('INBOUND_CODE_POLICY_NOT_AUTO')
|
||||
let sharedResources = null
|
||||
if (['pending-review', 'wait-split'].includes(stage)) {
|
||||
let dimensionRules = null
|
||||
if (stage === 'wait-split') {
|
||||
const dimensionConfig = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`)
|
||||
totalPreservingShapes(normalized.orders.find((order) => order.problems.length === 0), dimensionConfig.rules)
|
||||
dimensionRules = dimensionConfig.rules
|
||||
}
|
||||
let productImageFileId = state.sharedResources?.productImageFileId
|
||||
if (!productImageFileId) {
|
||||
const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images',
|
||||
{ content: PNG, type: 'image/png', name: `TEST-XLSX-${state.identity.batchId}.png` }, merchant))
|
||||
productImageFileId = image?.fileId
|
||||
if (!productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_ID_MISSING')
|
||||
}
|
||||
state.sharedResources = { productImageFileId, ...(dimensionRules ? { dimensionRules } : {}) }
|
||||
sharedResources = state.sharedResources
|
||||
await save()
|
||||
}
|
||||
const candidates = [], rejected = [...normalized.excluded]
|
||||
for (const order of normalized.orders) {
|
||||
try { candidates.push({ order, route: matchRoute(order, catalog) }) }
|
||||
@@ -284,47 +318,88 @@ async function main() {
|
||||
console.log(JSON.stringify({ stage, sourceRows: preflight.sourceRows, orders: preflight.orders, matched: candidates.length, rejected: rejected.length }))
|
||||
await save()
|
||||
if (stage === 'preflight') return
|
||||
await executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage })
|
||||
await executeCandidates({ candidates, limit, concurrency, state, save, api, ctx, merchant, profile, pallets,
|
||||
sharedResources, stage })
|
||||
} finally {
|
||||
await saveQueue.catch(() => {})
|
||||
await lock.close()
|
||||
const { unlink } = await import('node:fs/promises')
|
||||
await unlink(resolve(directory, 'run.lock'))
|
||||
await unlink(lockPath)
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) {
|
||||
let attempted = 0, succeeded = 0, failed = 0, skipped = 0
|
||||
async function acquireRunLock(lockPath) {
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx')
|
||||
await handle.writeFile(String(process.pid), 'utf8')
|
||||
return handle
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error
|
||||
const existingPid = Number(await readFile(lockPath, 'utf8').catch(() => ''))
|
||||
let alive = false
|
||||
if (Number.isInteger(existingPid) && existingPid > 0) {
|
||||
try { process.kill(existingPid, 0); alive = true } catch { /* Stale PID. */ }
|
||||
}
|
||||
if (alive) throw Error(`批次正在运行,pid=${existingPid}`)
|
||||
await unlink(lockPath)
|
||||
const handle = await open(lockPath, 'wx')
|
||||
await handle.writeFile(String(process.pid), 'utf8')
|
||||
return handle
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCandidates({ candidates, limit, concurrency = 1, state, save, api, ctx, merchant, profile, pallets,
|
||||
sharedResources, stage }) {
|
||||
let attempted = 0, completed = 0, succeeded = 0, failed = 0, skipped = 0, stopReason = null
|
||||
const pending = []
|
||||
for (const { order, route } of candidates) {
|
||||
// An interrupted write is not permission to create another order. Keep its last
|
||||
// checkpoint for inspection. A reset database is always a NEW batch/directory.
|
||||
if (state.orders[order.key]) { skipped++; continue }
|
||||
if (attempted >= limit) break
|
||||
const previous = state.orders[order.key]
|
||||
const retryablePricingWarning = stage === 'pending-review' && previous?.status === 'FAILED'
|
||||
&& previous.stage === 'estimate' && !previous.orderId && ['A0404', 'A0422'].includes(previous.evidence?.code)
|
||||
if (previous && !retryablePricingWarning) { skipped++; continue }
|
||||
if (pending.length >= limit) break
|
||||
pending.push({ order, route, previous })
|
||||
}
|
||||
for (let offset = 0; offset < pending.length; offset += concurrency) {
|
||||
const batch = pending.slice(offset, offset + concurrency)
|
||||
await Promise.all(batch.map(async ({ order, route, previous }) => {
|
||||
const marker = `XLSX-${hash(`${state.identity.batchId}:${order.key}`).slice(0, 24)}`
|
||||
const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString() }
|
||||
const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString(),
|
||||
...(previous ? { priorAttempts: [...(previous.priorAttempts || []), { status: previous.status, stage: previous.stage,
|
||||
error: previous.error, evidence: previous.evidence, finishedAt: previous.finishedAt }] } : {}) }
|
||||
state.orders[order.key] = entry
|
||||
await save()
|
||||
attempted++
|
||||
try {
|
||||
const estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', {
|
||||
originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode,
|
||||
destinationCode: route.destination.warehouseCode, destinationType: order.destinationType,
|
||||
destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName,
|
||||
pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode,
|
||||
actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm,
|
||||
}, merchant)
|
||||
if (estimate?.channelCode !== route.channel.channelCode || estimate?.pricingTaxMode !== order.pricingTaxMode) throw Error('ESTIMATE_ROUTE_MISMATCH')
|
||||
let estimate = null
|
||||
try {
|
||||
estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', {
|
||||
originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode,
|
||||
destinationCode: route.destination.warehouseCode, destinationType: order.destinationType,
|
||||
destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName,
|
||||
pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode,
|
||||
actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm,
|
||||
}, merchant)
|
||||
} catch (error) {
|
||||
if (stage !== 'pending-review' || !['A0404', 'A0422'].includes(error.evidence?.code)) throw error
|
||||
entry.pricingWarning = { code: error.evidence.code, message: error.evidence.message,
|
||||
requestId: error.evidence.requestId, traceId: error.evidence.traceId }
|
||||
}
|
||||
if (estimate && (estimate.channelCode !== route.channel.channelCode || estimate.pricingTaxMode !== order.pricingTaxMode)) {
|
||||
throw Error('ESTIMATE_ROUTE_MISMATCH')
|
||||
}
|
||||
let payload
|
||||
if (stage === 'create') {
|
||||
payload = buildSourceDraft(order, route, profile.tenantName, marker)
|
||||
} else {
|
||||
const config = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`)
|
||||
entry.dimensionPlan = totalPreservingShapes(order, config.rules)
|
||||
entry.stage = 'product-image'; await save()
|
||||
const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images',
|
||||
{ content: PNG, type: 'image/png', name: `${marker}-TEST.png` }, merchant))
|
||||
if (!image?.fileId) throw Error('PRODUCT_IMAGE_ID_MISSING')
|
||||
entry.productImageFileId = image.fileId
|
||||
payload = buildPayload(order, route, profile.tenantName, image.fileId, marker)
|
||||
if (!sharedResources?.productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_MISSING')
|
||||
if (stage === 'wait-split') {
|
||||
if (!Array.isArray(sharedResources.dimensionRules)) throw Error('SHARED_DIMENSION_RULES_MISSING')
|
||||
entry.dimensionPlan = totalPreservingShapes(order, sharedResources.dimensionRules)
|
||||
}
|
||||
entry.productImageFileId = sharedResources.productImageFileId
|
||||
payload = buildPayload(order, route, profile.tenantName, sharedResources.productImageFileId, marker)
|
||||
}
|
||||
entry.stage = 'create'; entry.payloadHash = hash(JSON.stringify(payload)); await save()
|
||||
entry.orderId = String(await api('POST', '/api/merchant/v1/orders/first-leg', payload, merchant,
|
||||
@@ -340,9 +415,25 @@ export async function executeCandidates({ candidates, limit, state, save, api, c
|
||||
|| Math.abs(Number(cargo.volumeCbm) - order.volumeCbm) > 0.00001) throw Error('DRAFT_SOURCE_FACTS_MISMATCH')
|
||||
entry.orderNo = draft.order.orderNo
|
||||
entry.declarationVerified = true
|
||||
if (stage === 'wait-split') {
|
||||
if (['pending-review', 'wait-split'].includes(stage)) {
|
||||
entry.stage = 'submit'; await save()
|
||||
await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` })
|
||||
}
|
||||
if (stage === 'pending-review') {
|
||||
const submitted = await api('GET', clientPath, undefined, merchant)
|
||||
const unified = await api('GET', `/api/merchant/v1/orders/${entry.orderId}`, undefined, merchant)
|
||||
if (submitted.order?.status !== 'SUBMITTED' || unified.summary?.mainStatus !== 'PENDING_REVIEW') {
|
||||
const error = Error('PENDING_REVIEW_NOT_CONFIRMED')
|
||||
error.evidence = { clientStatus: submitted.order?.status, mainStatus: unified.summary?.mainStatus }
|
||||
throw error
|
||||
}
|
||||
entry.stage = 'PENDING_REVIEW'
|
||||
entry.verification = { clientStatus: submitted.order.status, mainStatus: unified.summary.mainStatus,
|
||||
expectedPackages: order.packageCount, actualPackages: Number(submitted.cargoes?.[0]?.packageCount),
|
||||
expectedCargoWeightKg: order.weightKg, actualCargoWeightKg: Number(submitted.cargoes?.[0]?.grossWeightKg),
|
||||
expectedVolumeCbm: order.volumeCbm, actualVolumeCbm: Number(submitted.cargoes?.[0]?.volumeCbm) }
|
||||
}
|
||||
if (stage === 'wait-split') {
|
||||
entry.stage = 'approve'; await save()
|
||||
const platformPath = `/api/platform/v1/orders/first-leg/${entry.orderId}`
|
||||
const q = `tenantId=${profile.id}`
|
||||
@@ -358,14 +449,22 @@ export async function executeCandidates({ candidates, limit, state, save, api, c
|
||||
entry.status = 'SUCCEEDED'; succeeded++
|
||||
} catch (error) {
|
||||
entry.status = 'FAILED'; entry.error = error.message; entry.evidence = error.evidence; failed++
|
||||
if (error.evidence?.code === 'A0401') stopReason ||= 'AUTHENTICATION_EXPIRED'
|
||||
}
|
||||
completed++
|
||||
entry.finishedAt = new Date().toISOString(); await save()
|
||||
console.log(JSON.stringify({ attempted, succeeded, failed, skipped, rows: entry.rows,
|
||||
stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, error: entry.error, evidence: entry.evidence }))
|
||||
// A small pilot must expose a common deployment/data defect before multiplying it.
|
||||
if (entry.status === 'FAILED' || completed % 100 === 0 || completed === pending.length) {
|
||||
console.log(JSON.stringify({ attempted, completed, succeeded, failed, skipped, rows: entry.rows,
|
||||
stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode,
|
||||
error: entry.error, evidence: entry.evidence }))
|
||||
}
|
||||
}))
|
||||
if (stopReason) break
|
||||
// A small concurrent pilot must expose a common deployment/data defect before multiplying it.
|
||||
if (failed >= 3 && succeeded === 0) break
|
||||
}
|
||||
state.lastRun = { attempted, succeeded, failed, skipped, stage, finishedAt: new Date().toISOString() }
|
||||
state.lastRun = { attempted, completed, succeeded, failed, skipped, concurrency, stage, stopReason,
|
||||
finishedAt: new Date().toISOString() }
|
||||
await save()
|
||||
if (failed) process.exitCode = 1
|
||||
}
|
||||
@@ -383,6 +482,34 @@ async function poll(read, predicate) {
|
||||
throw error
|
||||
}
|
||||
|
||||
export async function saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker,
|
||||
attempts = 3, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), onRetry }) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const measured = await api('GET', measurementPath)
|
||||
if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS')
|
||||
const payload = {
|
||||
measurementId: measured.measurementId, version: Number(measured.version),
|
||||
actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128),
|
||||
items: entry.dimensionPlan.map((group) => {
|
||||
const item = measured.items.find((value) => String(value.id) === group.itemId)
|
||||
if (!item) throw Error('MEASUREMENT_GROUP_MISSING')
|
||||
return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
|
||||
packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height,
|
||||
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
|
||||
outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height,
|
||||
coefficientMode: 'ADD', remark: `${marker} TEST按总体积分组反算箱规,非实测` }
|
||||
}),
|
||||
}
|
||||
try { return await api('PUT', `${base}/measurement/draft?${q}`, payload) }
|
||||
catch (error) {
|
||||
if (error.evidence?.code !== 'S0500' || attempt === attempts) throw error
|
||||
await onRetry?.({ attempt, code: error.evidence.code, requestId: error.evidence.requestId, traceId: error.evidence.traceId })
|
||||
await wait(250 * attempt)
|
||||
}
|
||||
}
|
||||
throw Error('MEASUREMENT_DRAFT_RETRY_EXHAUSTED')
|
||||
}
|
||||
|
||||
async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) {
|
||||
const inboundId = entry.inboundId, q = `tenantId=${tenantId}`, marker = entry.marker
|
||||
const inboundPath = `/api/platform/inbounds/${inboundId}?${q}`
|
||||
@@ -411,21 +538,8 @@ async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save })
|
||||
group.itemId = String(scan.itemId)
|
||||
cartonIndex += group.count
|
||||
}
|
||||
const measured = await api('GET', measurementPath)
|
||||
if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS')
|
||||
const draft = await api('PUT', `${base}/measurement/draft?${q}`, {
|
||||
measurementId: measured.measurementId, version: Number(measured.version),
|
||||
actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128),
|
||||
items: entry.dimensionPlan.map((group) => {
|
||||
const item = measured.items.find((value) => String(value.id) === group.itemId)
|
||||
if (!item) throw Error('MEASUREMENT_GROUP_MISSING')
|
||||
return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
|
||||
packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height,
|
||||
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
|
||||
outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height,
|
||||
coefficientMode: 'ADD',
|
||||
remark: `${marker} TEST按总体积分组反算箱规,非实测` } }),
|
||||
})
|
||||
const draft = await saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker,
|
||||
onRetry: async (retry) => { entry.retries ||= []; entry.retries.push({ stage: 'measurement-draft', ...retry }); await save() } })
|
||||
const draftCheck = await api('GET', measurementPath)
|
||||
if (Math.abs(Number(draftCheck.actualVolumeCbm) - order.volumeCbm) > 0.0000001
|
||||
|| Number(draftCheck.actualPackageCount) !== order.packageCount) throw Error('DRAFT_VOLUME_OR_COUNT_MISMATCH')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDraft, buildPayload,
|
||||
requireSuccess, executeCandidates, totalPreservingShapes } from './real-order-xlsx-test-env.mjs'
|
||||
requireSuccess, executeCandidates, totalPreservingShapes, saveMeasurementDraft } from './real-order-xlsx-test-env.mjs'
|
||||
|
||||
const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号']
|
||||
const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1']
|
||||
@@ -39,6 +39,15 @@ test('split rows aggregate actual counts once and reconcile forecast', () => {
|
||||
b[4] = 15
|
||||
assert.ok(normalizeRows([header, a, b]).orders[0].problems.includes('SPLIT_COUNT_MISMATCH'))
|
||||
})
|
||||
test('split weight and volume totals use the persisted order precision', () => {
|
||||
const a = [...row], b = [...row]
|
||||
a[4] = 90; b[4] = 300; a[7] = 390; b[7] = 390
|
||||
a[5] = 1137.461538; b[5] = 3791.538
|
||||
a[6] = 7.256538462; b[6] = 24.188
|
||||
const result = normalizeRows([header, a, b]).orders[0]
|
||||
assert.equal(result.weightKg, 4929)
|
||||
assert.equal(result.volumeCbm, 31.4445)
|
||||
})
|
||||
test('conflicting channels on same inbound code block import', () => {
|
||||
const b = [...row]; b[2] = '其他包税'
|
||||
assert.ok(normalizeRows([header, row, b]).orders[0].problems.includes('CONFLICT_渠道'))
|
||||
@@ -105,9 +114,106 @@ test('HTTP business errors preserve diagnostic evidence', () => {
|
||||
assert.throws(() => requireSuccess({ http: 200, path: '/example', body: { code: 'A0409' }, requestId: 'req' }),
|
||||
(error) => error.evidence.code === 'A0409' && error.evidence.requestId === 'req')
|
||||
})
|
||||
test('measurement draft retries a transient server error after refreshing the version', async () => {
|
||||
let reads = 0, writes = 0
|
||||
const retries = []
|
||||
const entry = { dimensionPlan: [{ itemId: 'item-1', count: 31, length: 40, width: 30, height: 30 }] }
|
||||
const api = async (method) => {
|
||||
if (method === 'GET') return { measurementId: 'measurement-1', version: ++reads,
|
||||
items: [{ id: 'item-1', inboundDetailId: 'detail-1', itemNo: 1, boxSpecification: 'TEST' }] }
|
||||
writes++
|
||||
if (writes === 1) { const error = Error('temporary'); error.evidence = { code: 'S0500', requestId: 'req-1' }; throw error }
|
||||
return { measurementId: 'measurement-1', version: 3 }
|
||||
}
|
||||
const result = await saveMeasurementDraft({ api, measurementPath: '/measurement', base: '/inbound/1', q: 'tenantId=1',
|
||||
order: order(), entry, marker: 'MARKER', wait: async () => {}, onRetry: async (retry) => retries.push(retry) })
|
||||
assert.equal(result.version, 3)
|
||||
assert.equal(reads, 2)
|
||||
assert.equal(writes, 2)
|
||||
assert.deepEqual(retries.map((retry) => retry.code), ['S0500'])
|
||||
})
|
||||
test('same batch never recreates an attempted order, even when interrupted', async () => {
|
||||
const value = order(), state = { identity: { batchId: 'test' }, orders: { [value.key]: { status: 'STARTED' } } }
|
||||
await executeCandidates({ candidates: [{ order: value, route: catalog }], limit: 1, state, save: async () => {},
|
||||
api: async () => { throw Error('must not call HTTP') } })
|
||||
assert.equal(state.lastRun.attempted, 0); assert.equal(state.lastRun.skipped, 1)
|
||||
})
|
||||
test('pending-review stage submits but never calls approval or measurement APIs', async () => {
|
||||
const value = order(), route = matchRoute(value, catalog)
|
||||
const state = { identity: { batchId: 'pending-review' }, orders: {} }
|
||||
const calls = []
|
||||
let detailReads = 0
|
||||
const api = async (method, path) => {
|
||||
calls.push(`${method} ${path}`)
|
||||
if (path.endsWith('/pricing/estimate')) return { channelCode: route.channel.channelCode, pricingTaxMode: value.pricingTaxMode }
|
||||
if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-1'
|
||||
if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') {
|
||||
detailReads++
|
||||
return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker,
|
||||
status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' },
|
||||
cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] }
|
||||
}
|
||||
if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } }
|
||||
if (method === 'POST' && path.endsWith('/submit')) return true
|
||||
throw Error(`unexpected call ${method} ${path}`)
|
||||
}
|
||||
await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state,
|
||||
save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' },
|
||||
sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' })
|
||||
assert.equal(Object.values(state.orders)[0].stage, 'PENDING_REVIEW')
|
||||
assert.equal(Object.values(state.orders)[0].verification.mainStatus, 'PENDING_REVIEW')
|
||||
assert.equal(calls.some((call) => /approve|measurement|inbounds/.test(call)), false)
|
||||
})
|
||||
test('pending-review retries an estimate-only pricing failure and records it as a warning', async () => {
|
||||
const value = order(), route = matchRoute(value, catalog)
|
||||
const state = { identity: { batchId: 'pricing-warning' }, orders: { [value.key]: { status: 'FAILED', stage: 'estimate',
|
||||
error: 'HTTP_200_CODE_A0404', evidence: { code: 'A0404', requestId: 'old-request' }, finishedAt: '2026-01-01T00:00:00Z' } } }
|
||||
let detailReads = 0
|
||||
const api = async (method, path) => {
|
||||
if (path.endsWith('/pricing/estimate')) { const error = Error('no price'); error.evidence = { code: 'A0404', message: 'no price' }; throw error }
|
||||
if (method === 'POST' && path === '/api/merchant/v1/orders/first-leg') return 'order-2'
|
||||
if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') {
|
||||
detailReads++
|
||||
return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker,
|
||||
status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' },
|
||||
cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] }
|
||||
}
|
||||
if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } }
|
||||
if (method === 'POST' && path.endsWith('/submit')) return true
|
||||
throw Error(`unexpected call ${method} ${path}`)
|
||||
}
|
||||
await executeCandidates({ candidates: [{ order: value, route }], limit: 1, concurrency: 1, state,
|
||||
save: async () => {}, api, merchant: 'merchant', profile: { tenantName: 'Example' },
|
||||
sharedResources: { productImageFileId: 'image-1' }, stage: 'pending-review' })
|
||||
const entry = state.orders[value.key]
|
||||
assert.equal(entry.status, 'SUCCEEDED')
|
||||
assert.equal(entry.pricingWarning.code, 'A0404')
|
||||
assert.equal(entry.priorAttempts.length, 1)
|
||||
})
|
||||
test('single process respects bounded concurrency while keeping per-order checkpoints', async () => {
|
||||
const base = order()
|
||||
const route = matchRoute(base, catalog)
|
||||
const candidates = Array.from({ length: 7 }, (_, index) => ({
|
||||
order: { ...base, key: `key-${index}`, sourceCode: `SRC-${index}`, rows: [index + 2] }, route,
|
||||
}))
|
||||
const state = { identity: { batchId: 'concurrency' }, orders: {} }
|
||||
let active = 0, maximum = 0, id = 0
|
||||
const api = async (method, path, body) => {
|
||||
if (path.endsWith('/pricing/estimate')) {
|
||||
active++; maximum = Math.max(maximum, active)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
active--
|
||||
return { channelCode: route.channel.channelCode, pricingTaxMode: base.pricingTaxMode }
|
||||
}
|
||||
if (method === 'POST') return String(++id)
|
||||
const marker = path.split('/').at(-1)
|
||||
return { order: { id: marker, orderNo: `ORDER-${marker}`, customerRemark: Object.values(state.orders)
|
||||
.find((entry) => entry.orderId === marker)?.marker }, cargoes: [{ packageCount: 31, grossWeightKg: 100, volumeCbm: 2 }] }
|
||||
}
|
||||
await executeCandidates({ candidates, limit: 7, concurrency: 3, state, save: async () => {}, api,
|
||||
profile: { tenantName: 'Example' }, stage: 'create' })
|
||||
assert.equal(maximum, 3)
|
||||
assert.equal(state.lastRun.attempted, 7)
|
||||
assert.equal(state.lastRun.succeeded, 7)
|
||||
assert.equal(Object.values(state.orders).filter((entry) => entry.status === 'SUCCEEDED').length, 7)
|
||||
})
|
||||
|
||||
@@ -130,6 +130,26 @@ test('development publishes every service on loopback without changing test defa
|
||||
assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/)
|
||||
})
|
||||
|
||||
test('Firstleg deployment passes terminal drayage SMTP settings into the container', () => {
|
||||
const firstlegService = serviceCompose.slice(
|
||||
serviceCompose.indexOf(' firstleg:\n'),
|
||||
serviceCompose.indexOf(' finance:\n')
|
||||
)
|
||||
for (const key of [
|
||||
'TERMINAL_DRAYAGE_MAIL_ENABLED',
|
||||
'TERMINAL_DRAYAGE_MAIL_HOST',
|
||||
'TERMINAL_DRAYAGE_MAIL_PORT',
|
||||
'TERMINAL_DRAYAGE_MAIL_USERNAME',
|
||||
'TERMINAL_DRAYAGE_MAIL_PASSWORD',
|
||||
'TERMINAL_DRAYAGE_MAIL_SSL',
|
||||
'TERMINAL_DRAYAGE_MAIL_SCAN_DELAY'
|
||||
]) {
|
||||
assert.match(firstlegService, new RegExp(`^ ${key}: ` + '\\$\\{' + key, 'm'))
|
||||
}
|
||||
assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_ENABLED/)
|
||||
assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD/)
|
||||
})
|
||||
|
||||
test('parallel image publications isolate registry credentials per Jenkins job', () => {
|
||||
const imageStage = pipeline.slice(
|
||||
pipeline.indexOf("stage('构建并发布单服务镜像')"),
|
||||
|
||||
Reference in New Issue
Block a user