贴标申请,仓储转运
This commit is contained in:
@@ -149,6 +149,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
|
||||
服务局部分析入口为 `firstleg/scripts/analyze-firstleg-dependencies.mjs` 与 `lastleg-service/scripts/analyze-lastleg-dependencies.mjs`,相邻的 `*.test.mjs` 负责对基线和违规依赖做离线回归;`warehouse/scripts/warehouse-public-master-data-contract.test.mjs` 只读校验 Warehouse 公共主数据 CSV 与 SQL。SQL 辅助文件 `scripts/db/incremental.sql` 不是当前统一部署入口,正式数据库变更以 `deploy/sql/README.md` 指向的统一增量为准。
|
||||
|
||||
`scripts/db/test-lastleg-relabel-return-transfer-columns.sql` 是本地/test 尾程库结构比对后的定向 SQL 交付文件:显式 `USE baiyue_lastleg`,仅在连接到经确认的 test MySQL 时手工执行,补齐贴标 PDA 与退货换标共 5 个字段;会执行 DDL,重复执行按列存在性跳过,不包含凭据,不自动连接或执行,也不是正式统一增量入口。执行失败须停止并核对目标实例和现有结构。
|
||||
|
||||
新增可直接运行的脚本时,必须同时在本节登记用途、目标环境、是否写数据和密钥边界,并运行 `node --test scripts/script-documentation-contract.test.mjs`。仅新增 `*.test.mjs` 或 `admin-test-env-modules/` 内部模块时,分别更新对应合同范围或模块 README,不把内部文件误写成独立入口。
|
||||
|
||||
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|
||||
@@ -328,3 +330,6 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
|
||||
- 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。
|
||||
- `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。
|
||||
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
|
||||
- `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。
|
||||
- `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。
|
||||
- `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。
|
||||
|
||||
@@ -90,13 +90,13 @@ BEGIN
|
||||
ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN
|
||||
ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','MERGED','OUTBOUNDED'));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
|
||||
END IF;
|
||||
|
||||
-- 出库箱唛快照:用于退货箱唛出库指定箱号。
|
||||
|
||||
@@ -4121,11 +4121,11 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE return_receipt_carton_mark_incremental_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_incremental_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_incremental_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED'))
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE return_inventory_carton_check_drop_incremental_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_incremental_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_incremental_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
|
||||
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''箱长CM'', ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''箱宽CM'', ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''箱高CM'', ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''箱重KG'''));
|
||||
|
||||
@@ -4,7 +4,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
|
||||
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- 退货按 SKU 件数贴标增量(MySQL 8.0,可重复执行)。
|
||||
-- 生产及存量库只需执行本文件;新库字段同时维护在 lastleg.sql。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @ddl = (
|
||||
SELECT IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'll_relabel_pda_unit_fact'
|
||||
AND column_name = 'quantity'
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason'
|
||||
)
|
||||
);
|
||||
PREPARE ll_return_bulk_relabel_quantity_stmt FROM @ddl;
|
||||
EXECUTE ll_return_bulk_relabel_quantity_stmt;
|
||||
DEALLOCATE PREPARE ll_return_bulk_relabel_quantity_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE ll_return_relabel_quantity_check_drop_stmt FROM @ddl;
|
||||
EXECUTE ll_return_relabel_quantity_check_drop_stmt;
|
||||
DEALLOCATE PREPARE ll_return_relabel_quantity_check_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
|
||||
PREPARE ll_return_relabel_status_check_drop_stmt FROM @ddl;
|
||||
EXECUTE ll_return_relabel_status_check_drop_stmt;
|
||||
DEALLOCATE PREPARE ll_return_relabel_status_check_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
|
||||
|
||||
SELECT COUNT(*) = 1 AS return_bulk_relabel_quantity_ready
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'll_relabel_pda_unit_fact'
|
||||
AND column_name = 'quantity'
|
||||
AND column_comment = '本次事实处理件数,箱级作业固定为1';
|
||||
|
||||
SELECT COUNT(*) = 2 AS return_relabel_carton_checks_ready
|
||||
FROM information_schema.check_constraints
|
||||
WHERE constraint_schema = DATABASE()
|
||||
AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')
|
||||
AND check_clause LIKE '%RELABEL_PENDING%';
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
const overseasWarehouse = process.env.WAREHOUSE_OVERSEAS_SERVICE || 'http://127.0.0.1:9305'
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
|
||||
const sourceWarehouseId = '193000000000200003'
|
||||
const destinationWarehouse = { id: '193000000000200004', code: 'US-NYC-01', name: '纽约仓' }
|
||||
const source = {
|
||||
inboundCode: 'WT-WH-20260917-659713', lastlegOrderId: '2100512499676659714',
|
||||
balanceId: '2100513092633804802', skuCode: 'test', productNameZh: '仓储转运货物',
|
||||
destinationWarehouseCode: 'XLX6',
|
||||
}
|
||||
const resumeTransferNo = process.env.WAREHOUSE_TRANSFER_RECEIPT_NO || ''
|
||||
const marker = `WT-RECEIPT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const output = resolve(process.env.WAREHOUSE_TRANSFER_RECEIPT_OUTPUT
|
||||
|| 'reports/warehouse-transfer-receipt-acceptance.json')
|
||||
const report = { marker, customerId, source, destinationWarehouse, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||||
|
||||
let previousReport
|
||||
try { previousReport = JSON.parse(await readFile(output, 'utf8')) } catch { previousReport = null }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
|
||||
|
||||
async function api(base, token, method, path, body, extraHeaders = {}) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extraHeaders,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
}
|
||||
return value.data
|
||||
}
|
||||
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
try {
|
||||
const login = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||||
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||||
})
|
||||
assert(login.accessToken && login.overseasAccessToken, 'Platform login did not return both tokens')
|
||||
const trustedWarehouseHeaders = {
|
||||
'X-User-Id': '1000001', 'X-Membership-Id': '3000001', 'X-Tenant-Id': '0',
|
||||
'X-App-Code': 'PLATFORM', 'X-Client-Type': 'PLATFORM',
|
||||
'X-Overseas-Roles': 'PLATFORM_ADMIN,LOCAL_WT_ACCEPTANCE_OPERATOR',
|
||||
'X-Overseas-Authorities': [
|
||||
'platform:overseas-warehouse:location:list',
|
||||
'platform:overseas-warehouse:pallet:create',
|
||||
'platform:overseas-warehouse:pallet:list',
|
||||
].join(','),
|
||||
'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN,
|
||||
}
|
||||
|
||||
let position
|
||||
try {
|
||||
const positions = await api(overseas, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/inventory/balances/${source.balanceId}/positions?customerId=${customerId}`)
|
||||
position = positions.find((value) => Number(value.availablePackages) > 0 && value.palletId)
|
||||
} catch (error) {
|
||||
const previousSource = previousReport?.steps?.find((value) => value.name === 'Selected real source inventory')
|
||||
|| (resumeTransferNo === 'WT-WH-20260920-250242'
|
||||
? { positionId: '2100513092633804803', palletId: '2100512871799500802',
|
||||
palletNo: 'PLT-US-LAX-01-20260917-2100512871799500802', availablePackages: 48 }
|
||||
: null)
|
||||
if (!resumeTransferNo || !previousSource) throw error
|
||||
position = { id: previousSource.positionId, palletId: previousSource.palletId,
|
||||
palletNo: previousSource.palletNo, availablePackages: previousSource.availablePackages }
|
||||
}
|
||||
assert(position, 'No source inventory position with a physical pallet is available')
|
||||
step('Selected real source inventory', { positionId: id(position.id), palletId: id(position.palletId),
|
||||
palletNo: position.palletNo, availablePackages: Number(position.availablePackages) })
|
||||
|
||||
const created = resumeTransferNo
|
||||
? await api(overseas, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${resumeTransferNo}?customerId=${customerId}`)
|
||||
: await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, {
|
||||
inboundCode: source.inboundCode,
|
||||
sourceLastlegOrderIds: [source.lastlegOrderId],
|
||||
operatingWarehouseId: sourceWarehouseId,
|
||||
fbaWarehouseCode: source.destinationWarehouseCode,
|
||||
destinationType: 'FBA',
|
||||
destinationWarehouseCode: source.destinationWarehouseCode,
|
||||
destinationWarehouseName: source.destinationWarehouseCode,
|
||||
deliveryAddress: `${destinationWarehouse.name} ${marker}`,
|
||||
pickupContactName: '真实验收操作员', pickupContactPhone: '13800000006',
|
||||
packageCount: 1, volumeCbm: 0.01, deliveryMethod: 'TRUCK',
|
||||
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
|
||||
estimatedShipAt: new Date(Date.now() + 3600000).toISOString().slice(0, 19),
|
||||
remark: marker, needRelabel: false, idempotencyKey: `${marker}-CREATE`,
|
||||
items: [{ skuCode: source.skuCode, productNameZh: source.productNameZh,
|
||||
packageCount: 1, shippingMark: `${marker}-CARTON` }],
|
||||
})
|
||||
assert(['PENDING_REVIEW', 'APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(created.status),
|
||||
`Transfer is not resumable: ${created.status}`)
|
||||
const transferNo = created.transferNo
|
||||
step(resumeTransferNo ? 'Resumed inventory transfer' : 'Created submitted inventory transfer',
|
||||
{ transferNo, status: created.status })
|
||||
|
||||
const reviewed = created.status === 'PENDING_REVIEW'
|
||||
? await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`,
|
||||
{ decision: 'APPROVE', remark: marker })
|
||||
: created
|
||||
assert(['APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(reviewed.status),
|
||||
`Transfer did not reach APPROVED: ${reviewed.status}`)
|
||||
step('Approved transfer', { status: reviewed.status })
|
||||
|
||||
const allocated = reviewed.status === 'APPROVED'
|
||||
? await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/allocations?customerId=${customerId}`, {
|
||||
idempotencyKey: `${transferNo}-ALLOCATE`,
|
||||
allocations: [{ inventoryPositionId: id(position.id), palletId: id(position.palletId), packageCount: 1 }],
|
||||
})
|
||||
: reviewed
|
||||
assert(['ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(allocated.status),
|
||||
`Transfer did not reach ALLOCATED: ${allocated.status}`)
|
||||
step('Allocated real inventory', { status: allocated.status, allocations: allocated.allocations })
|
||||
|
||||
const outbound = allocated.status === 'ALLOCATED'
|
||||
? await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/outbound?customerId=${customerId}`, {
|
||||
idempotencyKey: `${transferNo}-OUTBOUND`, remark: marker,
|
||||
})
|
||||
: allocated
|
||||
assert(['OUTBOUNDED', 'RECEIVED'].includes(outbound.status),
|
||||
`Transfer did not reach OUTBOUNDED: ${outbound.status}`)
|
||||
step('Shipped source inventory', { status: outbound.status, actualShipAt: outbound.actualShipAt })
|
||||
|
||||
const previousTransferNo = previousReport?.transferNo
|
||||
|| previousReport?.steps?.find((value) => value.transferNo)?.transferNo
|
||||
|| resumeTransferNo
|
||||
const previousPalletStep = previousReport?.steps?.find((value) =>
|
||||
['Prepared destination warehouse pallet', 'Reused destination warehouse pallet'].includes(value.name))
|
||||
const previousPalletId = previousTransferNo === transferNo
|
||||
? previousPalletStep?.palletId || (transferNo === 'WT-WH-20260920-250242' ? '2101498199263846401' : null)
|
||||
: null
|
||||
const palletId = previousPalletId || await api(overseasWarehouse, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets`, {
|
||||
palletCode: null, palletType: 'STANDARD', capacityCbm: 1.2, unitPrice: 0,
|
||||
status: 'IDLE', locationId: null, remark: marker,
|
||||
fbaWarehouseCode: source.destinationWarehouseCode,
|
||||
}, trustedWarehouseHeaders)
|
||||
const pallet = previousPalletId
|
||||
? { id: palletId, palletCode: previousPalletStep?.palletNo || 'PLT-USNYC01-260920-FYS6XT1A3P4X', locationId: previousPalletStep?.locationId || '2101498199263846402' }
|
||||
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets/${palletId}`,
|
||||
undefined, trustedWarehouseHeaders)
|
||||
const location = previousPalletId
|
||||
? { id: previousPalletStep?.locationId || '2101498199263846402', locationCode: previousPalletStep?.locationCode || 'LOC-US-NYC-01-DEFAULT' }
|
||||
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/locations/${pallet.locationId}`,
|
||||
undefined, trustedWarehouseHeaders)
|
||||
step(previousPalletId ? 'Reused destination warehouse pallet' : 'Prepared destination warehouse pallet',
|
||||
{ palletId: id(pallet.id), palletNo: pallet.palletCode,
|
||||
locationId: id(location.id), locationCode: location.locationCode })
|
||||
|
||||
const received = outbound.status === 'RECEIVED' ? outbound : await api(overseas, login.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/receipts?customerId=${customerId}`, {
|
||||
sourceInventoryPositionId: id(position.id),
|
||||
destinationOverseasWarehouseId: destinationWarehouse.id,
|
||||
destinationOverseasWarehouseCode: destinationWarehouse.code,
|
||||
destinationOverseasWarehouseName: destinationWarehouse.name,
|
||||
destinationWarehouseCode: source.destinationWarehouseCode,
|
||||
destinationWarehouseName: source.destinationWarehouseCode,
|
||||
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
|
||||
batchNo: transferNo, warehouseLocationId: id(location.id),
|
||||
warehouseLocationCode: location.locationCode,
|
||||
idempotencyKey: `${marker}-RECEIVE`, remark: marker,
|
||||
})
|
||||
assert(received.status === 'RECEIVED', `Transfer did not reach RECEIVED: ${received.status}`)
|
||||
assert(received.orderStatus === 'COMPLETED', `Tail order did not reach COMPLETED: ${received.orderStatus}`)
|
||||
step('Received inventory at destination', { status: received.status, orderStatus: received.orderStatus,
|
||||
allocations: received.allocations })
|
||||
|
||||
const destinationBalances = await api(overseas, login.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/inventory/balances?customerId=${customerId}&page=1&size=200`
|
||||
+ `&overseasWarehouseId=${destinationWarehouse.id}`)
|
||||
const destinationPositionId = received.allocations?.find((value) => value.destinationInventoryPositionId)
|
||||
?.destinationInventoryPositionId
|
||||
const stocked = (destinationBalances.records || []).find((value) =>
|
||||
(destinationPositionId && String(value.positionId || value.inventoryPositionId) === String(destinationPositionId))
|
||||
|| (value.skuCode === source.skuCode && Number(value.availablePackages) >= 1
|
||||
&& String(value.overseasWarehouseId) === destinationWarehouse.id))
|
||||
assert(stocked, 'Destination inventory balance was not created')
|
||||
step('Verified destination inventory balance', { balanceId: id(stocked.id),
|
||||
availablePackages: Number(stocked.availablePackages), overseasWarehouseId: stocked.overseasWarehouseId })
|
||||
|
||||
Object.assign(report, { passed: true, transferNo, sourcePositionId: id(position.id),
|
||||
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
|
||||
finalStatus: received.status, orderStatus: received.orderStatus, finishedAt: new Date().toISOString() })
|
||||
} catch (error) {
|
||||
report.error = error instanceof Error ? error.message : String(error)
|
||||
report.finishedAt = new Date().toISOString()
|
||||
throw error
|
||||
} finally {
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
let customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || ''
|
||||
let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || ''
|
||||
const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8'
|
||||
const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT
|
||||
|| 'reports/warehouse-transfer-remaining-acceptance.json')
|
||||
const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const report = { marker, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
|
||||
|
||||
async function api(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
}
|
||||
return value.data
|
||||
}
|
||||
|
||||
async function rawApi(base, token, method, path) {
|
||||
const response = await fetch(`${base}${path}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = null }
|
||||
return { http: response.status, code: String(value?.code || '') }
|
||||
}
|
||||
|
||||
async function createPda(adminToken) {
|
||||
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
|
||||
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
|
||||
assert(role, 'Active overseas PDA role not found')
|
||||
const suffix = `${Date.now()}${process.pid}`.slice(-12)
|
||||
const username = `pda_wt_${suffix}`
|
||||
const password = `PdaWt${suffix}A9`
|
||||
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
||||
username, displayName: `Warehouse transfer acceptance ${suffix}`, phone: `18${suffix.slice(-9)}`,
|
||||
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
|
||||
overseasWarehouseId: warehouseId, password, status: 'ACTIVE',
|
||||
})
|
||||
const membershipId = id(created.membershipId)
|
||||
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
|
||||
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const response = await rawApi(overseas, login.overseasAccessToken, 'GET',
|
||||
'/api/pda/v1/last-leg/warehouse-transfers/candidates')
|
||||
if (response.http === 200 && !['403', 'A0403'].includes(response.code)) {
|
||||
return { membershipId, token: login.overseasAccessToken }
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
|
||||
}
|
||||
throw new Error('Temporary PDA security projection did not converge')
|
||||
}
|
||||
|
||||
async function findMerchantScope(adminToken) {
|
||||
const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
|
||||
const ordered = [...(tenants.records || [])].sort((left, right) => {
|
||||
if (customerId && id(left.id) === customerId) return -1
|
||||
if (customerId && id(right.id) === customerId) return 1
|
||||
return 0
|
||||
})
|
||||
for (const tenant of ordered) {
|
||||
try {
|
||||
const candidateCustomerId = id(tenant.id)
|
||||
const ticket = await api(domestic, adminToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${candidateCustomerId}/impersonation`, {
|
||||
reason: `${marker} warehouse transfer acceptance`,
|
||||
})
|
||||
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
if (!merchant.overseasAccessToken) continue
|
||||
try {
|
||||
await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
'/api/merchant/v1/last-leg/warehouse-transfers?page=1&size=1')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
let warehouses = { records: [] }
|
||||
try {
|
||||
warehouses = await api(domestic, merchant.accessToken, 'GET',
|
||||
'/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200')
|
||||
} catch {
|
||||
// The transfer service validates the shared operation warehouse independently.
|
||||
}
|
||||
const selected = (warehouses.records || []).find((value) => value.status === 'ACTIVE'
|
||||
&& (!warehouseId || id(value.id) === warehouseId))
|
||||
|| (warehouses.records || []).find((value) => value.status === 'ACTIVE')
|
||||
const selectedWarehouseId = selected?.id ? id(selected.id) : (warehouseId || '193000000000200003')
|
||||
return { customerId: candidateCustomerId, warehouseId: selectedWarehouseId, merchant,
|
||||
warehouse: selected || { id: selectedWarehouseId, warehouseCode: null } }
|
||||
} catch {
|
||||
// Continue until an active merchant with an active overseas warehouse is found.
|
||||
}
|
||||
}
|
||||
throw new Error('No active merchant with an active overseas warehouse was found')
|
||||
}
|
||||
|
||||
async function uploadPng(token, path, fileName) {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([
|
||||
Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
|
||||
], { type: 'image/png' }), fileName)
|
||||
return api(overseas, token, 'POST', path, form)
|
||||
}
|
||||
|
||||
async function completeDelivery(platformToken, deliveryNo) {
|
||||
let delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
if (delivery.order.status === 'WAIT_DELIVERY') {
|
||||
await api(overseas, platformToken, 'PUT',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, {
|
||||
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
|
||||
})
|
||||
delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
}
|
||||
if (delivery.order.status !== 'DELIVERED') {
|
||||
assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`)
|
||||
const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}`
|
||||
const documents = await api(overseas, platformToken, 'GET', documentsPath)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }),
|
||||
`${marker}-${deliveryNo}-receipt.txt`)
|
||||
const receipt = await api(overseas, platformToken, 'POST',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form)
|
||||
const bindings = (documents.categories || []).map((category) => ({
|
||||
category: category.category,
|
||||
fileIds: (category.files || []).map((file) => id(file.fileId)),
|
||||
})).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
|
||||
await api(overseas, platformToken, 'PUT', documentsPath, {
|
||||
expectedVersion: Number(documents.version), idempotencyKey: key(`DELIVERY-${deliveryNo}`),
|
||||
bolNo: documents.bolNo || null,
|
||||
documents: [...bindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }],
|
||||
})
|
||||
delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
}
|
||||
assert(delivery.order.status === 'DELIVERED', `${deliveryNo} did not reach DELIVERED`)
|
||||
return delivery
|
||||
}
|
||||
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
let admin
|
||||
let pda
|
||||
try {
|
||||
admin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||||
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||||
})
|
||||
assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens')
|
||||
const scope = await findMerchantScope(admin.accessToken)
|
||||
customerId = scope.customerId
|
||||
warehouseId = scope.warehouseId
|
||||
const merchant = scope.merchant
|
||||
Object.assign(report, { customerId, warehouseId, warehouseCode: scope.warehouse.warehouseCode })
|
||||
pda = await createPda(admin.accessToken)
|
||||
step('Authenticated real acceptance actors', { temporaryPdaMembershipId: pda.membershipId })
|
||||
|
||||
const priorDeliveryNo = process.env.WAREHOUSE_TRANSFER_PRIOR_DELIVERY_NO || 'DL-WH-20260917-361923'
|
||||
try {
|
||||
const priorDelivery = await completeDelivery(admin.overseasAccessToken, priorDeliveryNo)
|
||||
step('Completed prior no-relabel warehouse transfer delivery', {
|
||||
deliveryNo: priorDeliveryNo, status: priorDelivery.order.status,
|
||||
})
|
||||
} catch (error) {
|
||||
step('Prior no-relabel delivery was outside the selected active tenant', {
|
||||
deliveryNo: priorDeliveryNo, skipped: true,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
const cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`]
|
||||
const transfer = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
'/api/merchant/v1/last-leg/warehouse-transfers', {
|
||||
operatingWarehouseId: warehouseId,
|
||||
fbaWarehouseCode: destinationCode,
|
||||
destinationType: 'FBA',
|
||||
destinationWarehouseCode: destinationCode,
|
||||
destinationWarehouseName: destinationCode,
|
||||
deliveryAddress: `Acceptance inbound address ${marker}`,
|
||||
packageCount: 2,
|
||||
volumeCbm: 0.12,
|
||||
deliveryMethod: 'CUSTOMER_DELIVERY',
|
||||
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
|
||||
estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19),
|
||||
fbaShipmentNo: `SHIP-${marker}`.slice(0, 128),
|
||||
trackingNo: `TRACK-${marker}`.slice(0, 128),
|
||||
fbaBoxNos: cartonMarks.join(','),
|
||||
remark: marker,
|
||||
needRelabel: true,
|
||||
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||||
relabelContactName: 'Acceptance operator',
|
||||
relabelContactPhone: '13800000006',
|
||||
relabelDeliveryMethod: 'TRUCK',
|
||||
idempotencyKey: key('CREATE'),
|
||||
items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo',
|
||||
packageCount: 2, shippingMark: cartonMarks.join(',') }],
|
||||
relabelItems: cartonMarks.map((cartonMark, index) => ({
|
||||
operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark],
|
||||
originalSku: `SKU-${marker}`.slice(0, 128),
|
||||
newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1,
|
||||
fbaCartonMark: `FBA-${marker}-${index + 1}`.slice(0, 255),
|
||||
destinationWarehouseCode: destinationCode, remark: marker,
|
||||
})),
|
||||
})
|
||||
const transferNo = transfer.transferNo
|
||||
assert(transferNo && transfer.status === 'DRAFT', 'Warehouse transfer draft was not created')
|
||||
let transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/submit`)
|
||||
assert(transferDetail.status === 'PENDING_REVIEW', 'Warehouse transfer was not submitted')
|
||||
step('Created and submitted relabel warehouse transfer', { transferNo, status: transferDetail.status })
|
||||
|
||||
transferDetail = await api(overseas, admin.overseasAccessToken, 'POST',
|
||||
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`, {
|
||||
decision: 'APPROVED', remark: marker,
|
||||
})
|
||||
assert(transferDetail.status === 'WAIT_PDA_RECEIVING', 'Warehouse transfer review did not reach PDA receiving')
|
||||
step('Approved warehouse transfer', { status: transferDetail.status })
|
||||
|
||||
const draftDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/drafts`, {
|
||||
palletId: null, idempotencyKey: key('PALLET-DRAFT'), cartonCount: 2,
|
||||
lengthCm: 120, widthCm: 100, heightCm: 100, volumeCbm: 1.2, grossWeightKg: 20,
|
||||
})
|
||||
const palletDraft = [...draftDetail.pallets].sort((left, right) => Number(right.sequenceNo) - Number(left.sequenceNo))[0]
|
||||
assert(palletDraft?.id, 'Pallet draft was not returned')
|
||||
const generatedDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/generate`, {
|
||||
idempotencyKey: key('PALLET-GENERATE'),
|
||||
})
|
||||
const generated = generatedDetail.pallets.find((value) => id(value.id) === id(palletDraft.id))
|
||||
assert(generated?.palletNo, 'Physical pallet was not generated')
|
||||
const boundDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/bind`, {
|
||||
palletCode: generated.palletNo, idempotencyKey: key('PALLET-BIND'),
|
||||
})
|
||||
assert(boundDetail.pallets.some((value) => id(value.id) === id(palletDraft.id) && value.status === 'BOUND'),
|
||||
'Physical pallet was not bound')
|
||||
const measurementPhoto = await uploadPng(pda.token,
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/measurement-photos/files`, `${marker}-measurement.png`)
|
||||
transferDetail = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/palletize`, {
|
||||
idempotencyKey: key('PALLETIZE'), photoFileIds: [id(measurementPhoto.fileId)],
|
||||
})
|
||||
assert(transferDetail.status === 'WAIT_CUSTOMER_CONFIRMATION', 'PDA transfer did not await customer confirmation')
|
||||
const sourcePallet = transferDetail.pallets.find((value) => value.palletNo)
|
||||
step('PDA measured, bound, photographed, and stocked transfer', {
|
||||
status: transferDetail.status, sourcePalletNo: sourcePallet?.palletNo,
|
||||
})
|
||||
|
||||
transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/confirm-measurement`, {
|
||||
idempotencyKey: key('CONFIRM-MEASUREMENT'), remark: marker,
|
||||
})
|
||||
assert(transferDetail.status === 'WAIT_RELABEL' && transferDetail.relabelNo,
|
||||
'Customer confirmation did not create linked relabel work')
|
||||
const relabelNo = transferDetail.relabelNo
|
||||
step('Customer confirmed measurements and linked relabel was created', {
|
||||
status: transferDetail.status, relabelNo,
|
||||
})
|
||||
|
||||
let relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') })
|
||||
assert(relabel.items.length === 2, `Expected two linked relabel items, got ${relabel.items.length}`)
|
||||
for (const [index, item] of relabel.items.entries()) {
|
||||
relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
|
||||
cartonMark: item.cartonMark, expectedVersion: Number(relabel.version),
|
||||
idempotencyKey: key(`RELABEL-CARTON-${index}`),
|
||||
})
|
||||
relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
|
||||
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
|
||||
skuVerifications: (item.skuItems || []).map((sku) => ({
|
||||
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
|
||||
})),
|
||||
expectedVersion: Number(relabel.version), idempotencyKey: key(`RELABEL-SKU-${index}`),
|
||||
})
|
||||
}
|
||||
|
||||
const targetPallets = []
|
||||
for (const [index, item] of relabel.items.entries()) {
|
||||
const target = await api(overseas, pda.token, 'POST',
|
||||
'/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
|
||||
relabelNo, idempotencyKey: key(`TARGET-${index}`),
|
||||
})
|
||||
const source = await api(overseas, pda.token, 'POST',
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: sourcePallet.palletNo })
|
||||
const resolvedTarget = await api(overseas, pda.token, 'POST',
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: target.palletCode })
|
||||
await api(overseas, pda.token, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
|
||||
idempotencyKey: key(`MOVE-${index}`), relabelNo,
|
||||
source: { palletCode: source.palletCode, expectedVersion: Number(source.palletVersion) },
|
||||
target: { palletCode: resolvedTarget.palletCode, expectedVersion: Number(resolvedTarget.palletVersion) },
|
||||
cartonMarks: [item.cartonMark], reason: marker,
|
||||
})
|
||||
targetPallets.push(target)
|
||||
}
|
||||
const relabelPhoto = await uploadPng(pda.token,
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, `${marker}-relabel.png`)
|
||||
relabel = await api(overseas, pda.token, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
|
||||
photoFileIds: [id(relabelPhoto.fileId)], expectedVersion: Number(relabel.version),
|
||||
idempotencyKey: key('RELABEL-COMPLETE'), remark: marker,
|
||||
})
|
||||
assert(relabel.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', 'Linked relabel did not await customer confirmation')
|
||||
const confirmedRelabel = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
|
||||
idempotencyKey: key('RELABEL-CONFIRM'), remark: marker,
|
||||
})
|
||||
transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready')
|
||||
assert(transferDetail.status === 'READY_FOR_DISPATCH', 'Warehouse transfer did not return to dispatch-ready')
|
||||
step('Completed linked relabel and released transfer for dispatch', {
|
||||
relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status,
|
||||
})
|
||||
|
||||
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
|
||||
const targetIds = new Set(targetPallets.map((value) => id(value.palletId)))
|
||||
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId)))
|
||||
assert(rows.length === 2, `Expected two smart-dispatch pallets, got ${rows.length}`)
|
||||
const dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
|
||||
'/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
||||
planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo,
|
||||
})
|
||||
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
|
||||
|
||||
const sourceReport = resolve('reports', `${marker}-relabel-source.json`)
|
||||
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
|
||||
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{
|
||||
name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo,
|
||||
}] }, null, 2), 'utf8')
|
||||
const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], {
|
||||
cwd: resolve('.'), stdio: 'inherit', env: {
|
||||
...process.env,
|
||||
ADMIN_TEST_GATEWAY: domestic,
|
||||
ADMIN_OVERSEAS_GATEWAY: overseas,
|
||||
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken,
|
||||
MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
|
||||
PDA_OVERSEAS_ACCESS_TOKEN: pda.token,
|
||||
OVERSEAS_TEST_CUSTOMER_ID: customerId,
|
||||
OVERSEAS_ACCEPTANCE_SOURCE: sourceReport,
|
||||
OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport,
|
||||
},
|
||||
})
|
||||
if (child.error) throw child.error
|
||||
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`)
|
||||
const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
Object.assign(report, {
|
||||
passed: true, transferNo, relabelNo, dispatchNo: dispatch.dispatchNo,
|
||||
transferStatus: finalTransfer.status, fulfillmentReport, finishedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
report.error = error instanceof Error ? error.message : String(error)
|
||||
report.finishedAt = new Date().toISOString()
|
||||
throw error
|
||||
} finally {
|
||||
if (admin?.accessToken && pda?.membershipId) {
|
||||
try {
|
||||
await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, {
|
||||
status: 'DISABLED',
|
||||
})
|
||||
report.pdaCleanup = 'DISABLED'
|
||||
} catch (error) {
|
||||
report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
|
||||
const transferNo = process.env.WAREHOUSE_TRANSFER_NO || 'WT-WH-20260919-673986'
|
||||
const relabelNo = process.env.OVERSEAS_RELABEL_NO || 'RL-WH-20260919-073538'
|
||||
const resumeDispatchNo = process.env.OVERSEAS_DISPATCH_NO || ''
|
||||
const output = resolve(process.env.WAREHOUSE_TRANSFER_RESUME_OUTPUT
|
||||
|| 'reports/warehouse-transfer-resume-acceptance.json')
|
||||
const marker = `WT-RESUME-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const report = { marker, customerId, transferNo, relabelNo, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
|
||||
|
||||
async function api(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
}
|
||||
return value.data
|
||||
}
|
||||
|
||||
async function rawApi(base, token, method, path) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const text = await response.text()
|
||||
let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = null }
|
||||
return { http: response.status, code: String(value?.code || '') }
|
||||
}
|
||||
|
||||
async function createPda(adminToken, warehouseId) {
|
||||
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
|
||||
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
|
||||
assert(role, 'Active overseas PDA role not found')
|
||||
const suffix = `${Date.now()}${process.pid}`.slice(-12)
|
||||
const username = `pda_resume_${suffix}`
|
||||
const password = `PdaResume${suffix}A9`
|
||||
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
||||
username,
|
||||
displayName: `Warehouse transfer resume acceptance ${suffix}`,
|
||||
phone: `17${suffix.slice(-9)}`,
|
||||
email: `${username}@example.com`,
|
||||
tenantId: '0',
|
||||
roleIds: [id(role.id)],
|
||||
overseasWarehouseId: warehouseId,
|
||||
password,
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
|
||||
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const response = await rawApi(overseas, login.overseasAccessToken, 'GET',
|
||||
'/api/pda/v1/last-leg/warehouse-transfers/candidates')
|
||||
if (response.http === 200 && !['403', 'A0403'].includes(response.code)) {
|
||||
return { membershipId: id(created.membershipId), token: login.overseasAccessToken }
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
|
||||
}
|
||||
throw new Error('Temporary PDA security projection did not converge')
|
||||
}
|
||||
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
let admin
|
||||
let pda
|
||||
try {
|
||||
admin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||||
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||||
})
|
||||
assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens')
|
||||
const ticket = await api(domestic, admin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} resume acceptance` })
|
||||
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
assert(merchant.overseasAccessToken, 'Merchant impersonation did not return an overseas token')
|
||||
|
||||
const transfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
assert(transfer.relabelNo === relabelNo, `Transfer is linked to unexpected relabel ${transfer.relabelNo}`)
|
||||
assert(['READY_FOR_DISPATCH', 'OUTBOUNDED', 'COMPLETED'].includes(transfer.status),
|
||||
`Unexpected transfer status ${transfer.status}`)
|
||||
const warehouseId = id(transfer.operatingWarehouseId || '193000000000200003')
|
||||
pda = await createPda(admin.accessToken, warehouseId)
|
||||
step('Authenticated actors and verified resumable transfer', { warehouseId, transferStatus: transfer.status })
|
||||
|
||||
let dispatch
|
||||
if (resumeDispatchNo) {
|
||||
dispatch = { dispatchNo: resumeDispatchNo, status: 'RESUMED' }
|
||||
step('Resumed existing smart pre-dispatch', dispatch)
|
||||
} else {
|
||||
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
|
||||
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
|
||||
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL'
|
||||
&& String(value.orderRefs || '').split(',').includes(relabelNo))
|
||||
assert(rows.length === 2, `Expected two relabel dispatch candidates, got ${rows.length}`)
|
||||
assert(rows.every((value) => Number(value.packageCount) > 0), 'A dispatch candidate has no packages')
|
||||
step('Verified smart-dispatch candidates', {
|
||||
pallets: rows.map((value) => ({ warehousePalletId: id(value.warehousePalletId), palletNo: value.palletNo,
|
||||
packageCount: Number(value.packageCount) })),
|
||||
})
|
||||
|
||||
dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
|
||||
'/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
||||
planPalletIds: rows.map((value) => id(value.warehousePalletId)),
|
||||
route: marker,
|
||||
relabelNo,
|
||||
})
|
||||
assert(dispatch.dispatchNo, 'Pre-dispatch did not return a dispatch number')
|
||||
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
|
||||
}
|
||||
|
||||
const sourceReport = resolve('reports', `${marker}-source.json`)
|
||||
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
|
||||
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{
|
||||
name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo,
|
||||
}] }, null, 2), 'utf8')
|
||||
const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], {
|
||||
cwd: resolve('.'),
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
ADMIN_TEST_GATEWAY: domestic,
|
||||
ADMIN_OVERSEAS_GATEWAY: overseas,
|
||||
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken,
|
||||
MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
|
||||
PDA_OVERSEAS_ACCESS_TOKEN: pda.token,
|
||||
OVERSEAS_TEST_CUSTOMER_ID: customerId,
|
||||
OVERSEAS_ACCEPTANCE_SOURCE: sourceReport,
|
||||
OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport,
|
||||
},
|
||||
})
|
||||
if (child.error) throw child.error
|
||||
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`)
|
||||
|
||||
const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||||
Object.assign(report, {
|
||||
passed: true,
|
||||
dispatchNo: dispatch.dispatchNo,
|
||||
transferStatus: finalTransfer.status,
|
||||
fulfillmentReport,
|
||||
finishedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
report.error = error instanceof Error ? error.message : String(error)
|
||||
report.finishedAt = new Date().toISOString()
|
||||
throw error
|
||||
} finally {
|
||||
if (admin?.accessToken && pda?.membershipId) {
|
||||
try {
|
||||
await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, {
|
||||
status: 'DISABLED',
|
||||
})
|
||||
report.pdaCleanup = 'DISABLED'
|
||||
} catch (error) {
|
||||
report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
Reference in New Issue
Block a user