Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-22 09:33:00 +08:00
28 changed files with 549 additions and 48 deletions
+11 -1
View File
@@ -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`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。
+1
View File
@@ -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') || '',
+2
View File
@@ -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;
+145
View File
@@ -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))
}
+72
View File
@@ -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))
}