Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
+11
-1
@@ -1,10 +1,20 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
`db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。
|
||||
|
||||
`local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。
|
||||
|
||||
`local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。
|
||||
|
||||
`db/local-dev-iam-number-sequence.sql`:经明确授权在 local/dev 的 `baiyue_iam` 补齐客户编号序列表,与 IAM 表结构基线一致;连接凭据由外部传入,无固定主机。创建表并初始化 `CUSTOMER_CODE`,事务锁定序列后按已有五位数字客户编码确定下限,已有较大序列不回退,不更新客户业务行。执行后输出表结构及序列,重复执行安全;失败停止,不清库,不用于区域空库初始化或生产迁移。
|
||||
|
||||
`local-return-relabel-acceptance.mjs`:仅本机网关的单票退货换标独立编排器。显式传入 `ADMIN_TEST_GATEWAY`、`ADMIN_OVERSEAS_GATEWAY`、平台账号密码、`OVERSEAS_TEST_CUSTOMER_ID` 和 `INTERNAL_SERVICE_TOKEN`,可指定 `OVERSEAS_TEST_WAREHOUSE_ID`;先核对本地服务数据库连接。复用已有启用客户,经正式代登录接口,新建唯一SKU和一件退货,审核、PDA签收、Print生成验收PDF、转换标、组托、合成照片完工、客户确认及实际换标库存出库。复用内部模块的步骤函数,不借用历史业务库存,不运行客户建档或全量边界。会创建并最终停用临时PDA,业务记录保留;报告由 `--output` 指定,默认 `reports/local-return-relabel-single-<marker>.json`,阶段检查点保存单号,失败停止且重跑前必须核对报告,不自动清理或自动续跑,不代表现场作业或页面验收。
|
||||
|
||||
`db/test-return-relabel-checks.sql`:测试库退货转换标检查约束定向修复,显式目标 `baiyue_lastleg.ll_return_inventory_carton`。与 Lastleg 基线保持一致,允许正数量的 `RELABEL_PENDING`,保留可用/已出库正数量与已合箱零数量限制。一条原子 ALTER 同时替换两个既有 CHECK,不修改业务行、不删除数据、不关闭约束;重复执行得到相同约束,旧约束不存在则报错停止。凭据由外部测试数据库连接提供,执行前需测试库操作授权;末尾只读输出约束定义供回查。不是生产迁移入口,不能替代完整 HTTP 换标回归。
|
||||
|
||||
`../deploy/scripts/deploy-blue-green-service.sh`:受控部署流程调用的单服务蓝绿发布与健康检查入口;只由部署/Jenkins 编排使用,不作为普通验收脚本直接运行。目标服务、环境、镜像和凭据均由外部参数提供,失败按部署脚本回滚规则处理。
|
||||
|
||||
`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。
|
||||
`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;修正未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单,并将已客户确认且换标完成、仍停在待换标/待分配的仓储转运单按目的仓恢复为待排车或待确认目的仓,同步尾程主单和换标履约状态;不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。
|
||||
|
||||
`db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。
|
||||
|
||||
|
||||
@@ -375,6 +375,7 @@ export function parseArguments(argv) {
|
||||
verifyPalletLocationGuide: values.has('verify-pallet-location-guide'),
|
||||
routeMode: values.get('route-mode') || 'fixed',
|
||||
quiet: values.get('quiet') === 'true',
|
||||
returnMainChainOnly: values.get('return-main-chain-only') === 'true',
|
||||
manualSplitFixtureReport: values.get('manual-split-fixture-report') || '',
|
||||
manualSplitPlanId: values.get('manual-split-plan-id') || '',
|
||||
manualPreloadSourceReport: values.get('manual-preload-source-report') || '',
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
||||
|
||||
单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。
|
||||
|
||||
普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。
|
||||
|
||||
尾程暂扣按真实入库阶段验收:`phase4-tail-order-controls` 在 PDA 入库前验证暂扣返回冲突并回查订单零漂移,继续人工节点及整柜计划转换;`phase4-overseas-pda-operations` 接收本轮完成库存后调用同模块的 `verifyReceivedOrderHold`,重新核对本轮客户、订单和 `INBOUND_COMPLETED` 状态,再执行暂扣、同键重放/异参冲突、解除。正向暂扣未执行或前置不足仍是失败,不以入库前拒绝代替正向通过。
|
||||
|
||||
@@ -479,7 +479,11 @@ export async function run(context) {
|
||||
trace(merchantSummary.response), OPTIONS)
|
||||
}
|
||||
|
||||
await runReturnBoundaries(context, { merchantToken, warehouse, picker, access })
|
||||
if (context.options.returnMainChainOnly) {
|
||||
context.createdResources.phase4OverseasReturnReceipt.excludedScope = 'Additional customer, cross-warehouse and concurrent return boundary scenarios';
|
||||
} else {
|
||||
await runReturnBoundaries(context, { merchantToken, warehouse, picker, access })
|
||||
}
|
||||
} finally {
|
||||
await disablePicker(context, picker, 91)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Target only an explicitly authorized local/dev connection and baiyue_iam.
|
||||
-- Preserve existing customer rows and never reset an existing sequence backwards.
|
||||
USE baiyue_iam;
|
||||
CREATE TABLE IF NOT EXISTS iam_number_sequence (
|
||||
sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型',
|
||||
next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (sequence_key)
|
||||
) ENGINE=InnoDB COMMENT='IAM业务编号序列表';
|
||||
START TRANSACTION;
|
||||
INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1);
|
||||
SELECT next_value FROM iam_number_sequence WHERE sequence_key='CUSTOMER_CODE' FOR UPDATE;
|
||||
SET @customer_code_floor = (
|
||||
SELECT COALESCE(MAX(CAST(tenant_code AS UNSIGNED)), 0) + 1
|
||||
FROM iam_tenant WHERE tenant_code REGEXP '^[0-9]{5}$'
|
||||
);
|
||||
UPDATE iam_number_sequence SET next_value=GREATEST(next_value,@customer_code_floor)
|
||||
WHERE sequence_key='CUSTOMER_CODE';
|
||||
COMMIT;
|
||||
SHOW CREATE TABLE iam_number_sequence;
|
||||
SELECT sequence_key,next_value FROM iam_number_sequence;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Local baiyue_lastleg only; retain every application and all idempotency constraints.
|
||||
USE baiyue_lastleg;
|
||||
SET @relabel_has_unique = (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application'
|
||||
AND index_name = 'uk_ll_relabel_application_order');
|
||||
SET @relabel_has_index = (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application'
|
||||
AND index_name = 'idx_ll_relabel_application_order');
|
||||
SET @relabel_index_sql = CASE
|
||||
WHEN @relabel_has_unique > 0 AND @relabel_has_index = 0 THEN
|
||||
'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order, ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)'
|
||||
WHEN @relabel_has_unique > 0 THEN
|
||||
'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order'
|
||||
WHEN @relabel_has_index = 0 THEN
|
||||
'ALTER TABLE ll_relabel_application ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)'
|
||||
ELSE 'SELECT 1' END;
|
||||
PREPARE relabel_index_migration FROM @relabel_index_sql;
|
||||
EXECUTE relabel_index_migration;
|
||||
DEALLOCATE PREPARE relabel_index_migration;
|
||||
@@ -13,4 +13,41 @@ WHERE o.order_type='RELABEL_APPLICATION' AND o.current_status='PENDING_ASSIGNMEN
|
||||
AND o.inbound_order_no IS NULL
|
||||
AND (a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING')
|
||||
OR (a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_PRE_DISPATCH','WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED')));
|
||||
|
||||
-- 仓储转运已完成入库和换标后不得回到“待分配”;按是否已有目的仓恢复后续状态。
|
||||
UPDATE ll_warehouse_transfer_order t
|
||||
JOIN ll_relabel_application a ON a.id=t.relabel_application_id
|
||||
AND a.customer_id=t.customer_id AND a.relabel_no=t.relabel_no
|
||||
SET t.status=CASE
|
||||
WHEN COALESCE(t.fba_warehouse_code,'')<>'' THEN 'READY_FOR_DISPATCH'
|
||||
ELSE 'WAIT_FBA_DESTINATION' END,
|
||||
t.relabel_completed_at=COALESCE(t.relabel_completed_at,a.customer_confirmed_at),
|
||||
t.updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE t.status='WAIT_RELABEL'
|
||||
AND a.customer_confirmed_at IS NOT NULL
|
||||
AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED');
|
||||
|
||||
UPDATE ll_order o
|
||||
JOIN ll_warehouse_transfer_order t ON t.lastleg_order_id=o.id AND t.customer_id=o.customer_id
|
||||
JOIN ll_relabel_application a ON a.id=t.relabel_application_id AND a.customer_id=t.customer_id
|
||||
SET o.current_status=t.status,
|
||||
o.relabel_status='COMPLETED',
|
||||
o.sub_order_status=CONCAT('WAREHOUSE_TRANSFER:',t.status,';RELABEL:COMPLETED'),
|
||||
o.updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE o.order_type='WAREHOUSE_TRANSFER'
|
||||
AND o.current_status IN ('PENDING_ASSIGNMENT','WAIT_OUTBOUND','INBOUND_COMPLETED')
|
||||
AND t.status IN ('READY_FOR_DISPATCH','WAIT_FBA_DESTINATION')
|
||||
AND a.customer_confirmed_at IS NOT NULL
|
||||
AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED');
|
||||
|
||||
UPDATE ll_relabel_application a
|
||||
JOIN ll_warehouse_transfer_order t ON t.relabel_application_id=a.id AND t.customer_id=a.customer_id
|
||||
SET a.fulfillment_status=CASE
|
||||
WHEN t.status='READY_FOR_DISPATCH' THEN 'WAIT_PRE_DISPATCH'
|
||||
ELSE 'WAIT_FBA_DESTINATION' END,
|
||||
a.updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE a.customer_confirmed_at IS NOT NULL
|
||||
AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED')
|
||||
AND t.status IN ('READY_FOR_DISPATCH','WAIT_FBA_DESTINATION')
|
||||
AND a.fulfillment_status IN ('WAIT_RELABEL','WAIT_OUTBOUND_CREATION','WAIT_PRE_DISPATCH','READY_FOR_DISPATCH','WAIT_FBA_DESTINATION');
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
|
||||
import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
|
||||
import { approveReturnFixture } from './admin-test-env-modules/phase4-overseas-return-receipt.mjs'
|
||||
import { completeReturnRelabel, outboundRelabelledReturn } from './admin-test-env-modules/phase4-overseas-return-followups.mjs'
|
||||
|
||||
const options = parseArguments(process.argv.slice(2))
|
||||
for (const url of [options.gateway, options.overseasGateway]) {
|
||||
if (!['127.0.0.1', 'localhost'].includes(new URL(url).hostname)) throw new Error('Local gateways required')
|
||||
}
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
||||
if (!customerId || !options.username || !options.password || !process.env.INTERNAL_SERVICE_TOKEN) throw new Error('Explicit customer, credentials and internal token required')
|
||||
const context = new AcceptanceRun(options)
|
||||
const verifyRemaining = process.env.LOCAL_RETURN_VERIFY_REMAINING === 'true'
|
||||
const receiptQuantity = verifyRemaining ? 3 : 1
|
||||
const output = resolve(options.output || `reports/local-return-relabel-single-${context.marker}.json`)
|
||||
const moduleName = 'local-return-relabel-single'
|
||||
let picker
|
||||
let fatal = null
|
||||
const facts = { marker: context.marker, customerId, scope: verifyRemaining ? 'REMAINING_RETURN_RELABEL' : 'ONE_NEW_RETURN_SKU_ON_EXISTING_LOCAL_CUSTOMER', photoEvidence: 'SYNTHETIC_TEST_IMAGE' }
|
||||
context.context.merchantQualificationFixture = { marker: context.marker, tenantId: customerId }
|
||||
context.createdResources.phase4OverseasReturnReceipt = facts
|
||||
const success = response => response.http === 200 && String(response.body?.code) === '200'
|
||||
async function save() {
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
const summary = buildAcceptanceSummary(context.steps, [], [])
|
||||
await writeFile(output, JSON.stringify({ success: !fatal && (verifyRemaining ? facts.remainingRelabelPassed === true : facts.relabelOutboundNo != null) && summary.failed === 0, fatal, summary, facts, steps: context.steps }, null, 2))
|
||||
}
|
||||
function check(name, condition) {
|
||||
context.assert(moduleName, name, null, Boolean(condition), '', { deploymentBlocker: true })
|
||||
if (!condition) throw new Error(name)
|
||||
}
|
||||
async function command(region, token, method, path, body, headers = {}) {
|
||||
const response = region === 'cn' ? await context.request(method, path, body, token)
|
||||
: await context.requestOverseas(method, path, body, headers, token)
|
||||
context.addStep(moduleName, `${method} ${path}`, response, success(response), '', { deploymentBlocker: true })
|
||||
if (!success(response)) throw new Error(`${method} ${path}: ${response.body?.code} ${response.body?.message}; requestId=${response.requestId}; traceId=${response.traceId}`)
|
||||
return response.body.data
|
||||
}
|
||||
async function upload(token, path, bytes, name, type) {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([bytes], { type }), name)
|
||||
const response = await context.requestOverseasMultipart('POST', path, form, token)
|
||||
context.addStep(moduleName, `Upload ${path}`, response, success(response), '', { deploymentBlocker: true })
|
||||
if (!success(response)) throw new Error(`Upload failed: ${response.body?.code} ${response.body?.message}`)
|
||||
return response.body.data
|
||||
}
|
||||
try {
|
||||
const login = await command('cn', '', 'POST', '/api/auth/login', { loginId: options.username, password: options.password })
|
||||
context.token = login.accessToken
|
||||
context.overseasToken = login.overseasAccessToken
|
||||
const ticket = await command('cn', context.token, 'POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${context.marker} 本地单票退货换标验收` })
|
||||
const merchant = await command('cn', '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
const token = merchant.overseasAccessToken
|
||||
const permissions = await command('cn', merchant.accessToken, 'GET', '/api/merchant/v1/iam/authorization/permissions')
|
||||
check('Merchant return create and relabel permissions', permissions.includes('merchant:overseas-warehouse:return-order:create') && permissions.includes('merchant:overseas-warehouse:return-order:relabel'))
|
||||
await command('os', token, 'GET', '/api/merchant/v1/last-leg/return-orders?page=1&size=1')
|
||||
const warehouses = await command('cn', context.token, 'GET', '/api/platform/v1/warehouse-admin?page=1&size=200&warehouseType=OVERSEAS&status=ACTIVE&capabilityCode=RECEIVING')
|
||||
const warehouse = warehouses.records.find(row => row.id === process.env.OVERSEAS_TEST_WAREHOUSE_ID) || warehouses.records[0]
|
||||
check('Existing active overseas receiving warehouse', warehouse?.id)
|
||||
const warehouseId = String(warehouse.id)
|
||||
facts.warehouseId = warehouseId
|
||||
let destination
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const data = await command('cn', context.token, 'GET', `/api/platform/v1/warehouse-admin?page=${page}&size=200&warehouseType=FBA&status=ACTIVE`)
|
||||
destination = data.records.find(row => row.warehouseCode === 'SCK8')
|
||||
if (destination || page * 200 >= Number(data.total)) break
|
||||
}
|
||||
check('Existing active SCK8 destination', destination?.id)
|
||||
const access = await resolvePickerAccess(context)
|
||||
check('PDA role available', access)
|
||||
picker = await createPicker(context, 93, warehouseId, access)
|
||||
check('PDA session ready', picker?.token)
|
||||
const skuCode = `LOCAL-RETURN-${Date.now()}`
|
||||
facts.skuCode = skuCode
|
||||
const root = '/api/merchant/v1/last-leg/return-orders'
|
||||
const receipts = '/api/pda/v1/last-leg/return-receipts'
|
||||
const created = await command('os', token, 'POST', root, { overseasWarehouseId: warehouseId, warehouseContactName: '本地验收', warehouseContactPhone: '0000000000', customerReferenceNo: context.marker, needRelabel: false, remark: context.marker, idempotencyKey: `${context.marker}-CREATE`, items: [{ skuCode, productNameZh: '本地退货换标验收商品', expectedQuantity: receiptQuantity }] })
|
||||
const returnNo = created.order.returnNo
|
||||
facts.returnNo = returnNo
|
||||
await save()
|
||||
await approveReturnFixture(context, token, created)
|
||||
await command('os', picker.token, 'POST', `${receipts}/start`, { returnNo })
|
||||
const pallet = await command('os', picker.token, 'POST', `${receipts}/pallets`, { returnNo, skuCode })
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')
|
||||
await upload(picker.token, `${receipts}/${returnNo}/photos/files`, png, 'synthetic-receipt.png', 'image/png')
|
||||
const receiptBody = { returnNo, startedBy: 'RETURN_NO', palletCode: pallet.palletCode, items: [{ skuCode, receivedQuantity: receiptQuantity }], idempotencyKey: `${context.marker}-RECEIPT` }
|
||||
const received = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody)
|
||||
const replay = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody)
|
||||
check('Receipt completed once with one stock and fee batch', received.orderStatus === 'COMPLETED' && Number(received.receivedQuantity) === receiptQuantity && received.receiptNo === replay.receiptNo && received.receiptBatchNo === replay.receiptBatchNo)
|
||||
facts.receiptNo = received.receiptNo
|
||||
facts.receiptBatchNo = received.receiptBatchNo
|
||||
facts.sourcePalletCode = pallet.palletCode
|
||||
await save()
|
||||
const relabel = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, { items: [{ skuCode, quantity: 1 }], fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: String(destination.id), destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: context.marker, idempotencyKey: `${context.marker}-RELABEL` })
|
||||
const relabelNo = relabel.relabelNo
|
||||
check('Relabel references the new return', relabel.returnNo === returnNo && relabel.status === 'PENDING_REVIEW' && relabelNo)
|
||||
facts.relabelNo = relabelNo
|
||||
await save()
|
||||
const rendered = await fetch('http://127.0.0.1:9207/internal/print/v1/pdfs/inbound-marks', { method: 'POST', headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN, 'X-Operator-Id': '1', 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'INBOUND_MARK_V1', pageSpec: { widthMm: 100, heightMm: 100 }, downloadFileName: 'local-return-relabel.pdf', labels: [{ topCode: skuCode, warehouseText: destination.warehouseCode, cartonText: 'QTY: 1', routeText: 'LOCAL ACCEPTANCE', qrContent: relabelNo }] }) })
|
||||
const pdf = Buffer.from(await rendered.arrayBuffer())
|
||||
check('Print service returned real PDF', rendered.ok && pdf.subarray(0, 5).toString() === '%PDF-')
|
||||
facts.labelPdfSha256 = createHash('sha256').update(pdf).digest('hex')
|
||||
const uploaded = await upload(token, `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/files?scope=CARTON_TOTAL`, pdf, 'local-return-relabel.pdf', 'application/pdf')
|
||||
facts.labelFileId = uploaded.fileId
|
||||
await completeReturnRelabel(context, token, picker.token, relabelNo, returnNo)
|
||||
await save()
|
||||
if (verifyRemaining) {
|
||||
const candidates = await command('os', token, 'GET', `${root}/${returnNo}/relabel-candidates`)
|
||||
check('Two unlabelled pieces remain on the source pallet', candidates.filter(row => row.cartonMark === pallet.palletCode).flatMap(row => row.items).reduce((sum, row) => sum + Number(row.receivedQuantity), 0) === 2)
|
||||
const body = { items: [{ skuCode, quantity: 1 }], fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: String(destination.id), destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: `${context.marker}-SECOND`, idempotencyKey: `${context.marker}-RELABEL-SECOND` }
|
||||
const second = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, body)
|
||||
check('Second application accepts remaining goods on same return', second.returnNo === returnNo && second.relabelNo !== relabelNo && second.status === 'PENDING_REVIEW')
|
||||
facts.secondRelabelNo = second.relabelNo
|
||||
await save()
|
||||
const repeated = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, body)
|
||||
check('Second application replay does not duplicate allocation', repeated.relabelNo === second.relabelNo)
|
||||
await upload(token, `/api/merchant/v1/last-leg/relabel-applications/${second.relabelNo}/files?scope=CARTON_TOTAL`, pdf, 'local-return-second.pdf', 'application/pdf')
|
||||
context.marker = `${facts.marker}-SECOND`
|
||||
try {
|
||||
await completeReturnRelabel(context, token, picker.token, second.relabelNo, returnNo)
|
||||
} finally {
|
||||
context.marker = facts.marker
|
||||
}
|
||||
const after = await command('os', token, 'GET', `${root}/${returnNo}/relabel-candidates`)
|
||||
check('Exactly one unlabelled piece remains after two completed batches', after.filter(row => row.cartonMark === pallet.palletCode).flatMap(row => row.items).reduce((sum, row) => sum + Number(row.receivedQuantity), 0) === 1)
|
||||
facts.remainingRelabelPassed = true
|
||||
} else {
|
||||
await outboundRelabelledReturn(context, token, picker.token, relabelNo, returnNo, warehouseId)
|
||||
}
|
||||
const final = (await command('os', token, 'GET', `${root}/${returnNo}`)).order
|
||||
facts.finalReturnStatus = final.status
|
||||
facts.finalRelabelStatus = final.relabelStatus
|
||||
} catch (error) {
|
||||
fatal = error.message
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (picker?.membershipId) {
|
||||
try { await disablePicker(context, picker, 93) } catch (error) { fatal = `${fatal || ''} cleanup: ${error.message}`; process.exitCode = 1 }
|
||||
}
|
||||
await save()
|
||||
console.log(JSON.stringify({ output, fatal, facts, summary: buildAcceptanceSummary(context.steps, [], []), failures: context.steps.filter(step => !step.passed) }, null, 2))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
|
||||
import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
|
||||
|
||||
const options = parseArguments(process.argv.slice(2))
|
||||
for (const url of [options.gateway, options.overseasGateway]) {
|
||||
if (!['127.0.0.1', 'localhost'].includes(new URL(url).hostname)) throw new Error('Local only')
|
||||
}
|
||||
if (!process.env.LOCAL_RETURN_SOURCE) throw new Error('LOCAL_RETURN_SOURCE required')
|
||||
const previous = JSON.parse(await readFile(process.env.LOCAL_RETURN_SOURCE, 'utf8'))
|
||||
const f = previous.facts
|
||||
if (!f.relabelCompleted || !f.returnNo || !f.relabelNo || !f.customerId || !f.warehouseId) throw new Error('Completed relabel evidence required')
|
||||
const c = new AcceptanceRun(options)
|
||||
const output = options.output || `reports/local-return-relabel-outbound-${c.marker}.json`
|
||||
let picker, error = null
|
||||
const report = { source: process.env.LOCAL_RETURN_SOURCE, facts: f, scope: 'PLATFORM_VERIFIED_OUTBOUND_ONLY', merchantInventoryRead: 'NOT_VERIFIED_SEE_SOURCE_REPORT' }
|
||||
function check(name, ok) {
|
||||
c.assert('local-outbound', name, null, Boolean(ok), '', { deploymentBlocker: true })
|
||||
if (!ok) throw new Error(name)
|
||||
}
|
||||
async function api(region, token, method, path, body) {
|
||||
const r = region === 'cn' ? await c.request(method, path, body, token) : await c.requestOverseas(method, path, body, {}, token)
|
||||
const ok = r.http === 200 && String(r.body?.code) === '200'
|
||||
c.addStep('local-outbound', `${method} ${path}`, r, ok, '', { deploymentBlocker: true })
|
||||
if (!ok) throw new Error(`${path}: ${r.body?.code} ${r.body?.message}`)
|
||||
return r.body.data
|
||||
}
|
||||
async function save() {
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
await writeFile(output, JSON.stringify({ ...report, error, summary: buildAcceptanceSummary(c.steps, [], []), steps: c.steps }, null, 2))
|
||||
}
|
||||
try {
|
||||
const l = await api('cn', '', 'POST', '/api/auth/login', { loginId: options.username, password: options.password })
|
||||
c.token = l.accessToken; c.overseasToken = l.overseasAccessToken
|
||||
const inv = '/api/platform/v1/last-leg/return-inventory/cartons'
|
||||
const root = '/api/platform/v1/last-leg/outbound-orders'
|
||||
const candidates = await api('os', c.overseasToken, 'GET', `${root}/return/candidates?customerId=${f.customerId}&warehouseId=${f.warehouseId}`)
|
||||
const matches = []
|
||||
for (const row of candidates.filter(x => x.returnNo === f.returnNo)) {
|
||||
const d = await api('os', c.overseasToken, 'GET', `${inv}/${encodeURIComponent(row.cartonMark)}`)
|
||||
if (d.relabelNo === f.relabelNo) matches.push(d)
|
||||
}
|
||||
check('Exactly one matching relabelled return carton', matches.length === 1)
|
||||
const d = matches[0]
|
||||
check('Customer warehouse quantity and completed relabel match', d.customerId === f.customerId && d.overseasWarehouseId === f.warehouseId && d.sourceReturnNo === f.returnNo && d.status === 'AVAILABLE' && d.relabelStatus === 'COMPLETED' && Number(d.totalQuantity) === 1)
|
||||
const access = await resolvePickerAccess(c)
|
||||
check('PDA role', access)
|
||||
picker = await createPicker(c, 94, f.warehouseId, access)
|
||||
check('PDA ready', picker?.token)
|
||||
const body = { recipient: 'LOCAL ACCEPTANCE', overseasWarehouseId: f.warehouseId, returnNo: f.returnNo, cartonMarks: [d.cartonMark], remark: f.marker, idempotencyKey: `${f.marker}-OUTBOUND` }
|
||||
const task = await api('os', c.overseasToken, 'POST', `${root}/return?customerId=${f.customerId}`, body)
|
||||
report.outboundNo = task.outboundNo; report.taskNo = task.taskNo
|
||||
await save()
|
||||
const replay = await api('os', c.overseasToken, 'POST', `${root}/return?customerId=${f.customerId}`, body)
|
||||
check('Outbound idempotent', replay.outboundNo === task.outboundNo && replay.taskNo === task.taskNo)
|
||||
const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}`
|
||||
const detail = await api('os', picker.token, 'GET', path)
|
||||
const frozen = await api('os', c.overseasToken, 'GET', `${root}/${task.outboundNo}?customerId=${f.customerId}`)
|
||||
check('Frozen outbound contains only matching carton', detail.items.length === 1 && Number(detail.items[0].packageCount) === 1 && frozen.pallets.length === 1 && frozen.pallets[0].cartonMarks === d.cartonMark && String(frozen.pallets[0].id) === String(detail.items[0].outboundPalletItemId))
|
||||
await api('os', picker.token, 'POST', `${path}/pick`, { outboundPalletItemId: detail.items[0].outboundPalletItemId, inventoryPositionId: null, packages: 1, cartonMarks: [d.cartonMark], idempotencyKey: `${f.marker}-PICK` })
|
||||
const done = await api('os', picker.token, 'POST', `${path}/complete`, { idempotencyKey: `${f.marker}-OUTBOUND-COMPLETE` })
|
||||
const final = await api('os', c.overseasToken, 'GET', `${root}/${task.outboundNo}?customerId=${f.customerId}`)
|
||||
const stock = await api('os', c.overseasToken, 'GET', `${inv}/${encodeURIComponent(d.cartonMark)}`)
|
||||
check('Outbound completed and stock OUTBOUNDED', done.status === 'COMPLETED' && final.outbound.status === 'OUTBOUNDED' && stock.status === 'OUTBOUNDED' && stock.relabelNo === f.relabelNo && stock.sourceReturnNo === f.returnNo && Number(stock.totalQuantity) === 1)
|
||||
report.finalOutboundStatus = final.outbound.status; report.finalInventoryStatus = stock.status; report.fulfillmentPassed = true
|
||||
} catch (e) { error = e.message; process.exitCode = 1 }
|
||||
finally {
|
||||
if (picker?.membershipId) { try { await disablePicker(c, picker, 94) } catch (e) { error = `${error || ''} cleanup: ${e.message}`; process.exitCode = 1 } }
|
||||
await save()
|
||||
console.log(JSON.stringify({ output, error, outboundNo: report.outboundNo, finalOutboundStatus: report.finalOutboundStatus, finalInventoryStatus: report.finalInventoryStatus, summary: buildAcceptanceSummary(c.steps, [], []), failures: c.steps.filter(x => !x.passed) }, null, 2))
|
||||
}
|
||||
Reference in New Issue
Block a user