优化提交
This commit is contained in:
@@ -49,3 +49,12 @@ test('module records the marker-owned dispatch without attempting excluded clean
|
||||
assert.match(source, /finalStatus = arranged\.data\?\.status/)
|
||||
assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/)
|
||||
})
|
||||
|
||||
test('module prefers marker-owned relabel pallets and proves only new FBA carton marks are frozen', () => {
|
||||
assert.match(source, /phase4OverseasRelabel/)
|
||||
assert.match(source, /sourceStage === 'RELABEL'/)
|
||||
assert.match(source, /relabel\.targetPalletIds\.includes/)
|
||||
assert.match(source, /relabelCartonMarks/)
|
||||
assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/)
|
||||
assert.match(source, /relabelNo: relabel\.relabelNo/)
|
||||
})
|
||||
|
||||
@@ -39,6 +39,49 @@ function markerFixture(context) {
|
||||
return { ...fixture, palletIds }
|
||||
}
|
||||
|
||||
async function runRelabelDispatch(context, relabel) {
|
||||
const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选',
|
||||
query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' }))
|
||||
const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : [])
|
||||
.filter((value) => value?.sourceStage === 'RELABEL'
|
||||
&& relabel.targetPalletIds.includes(id(value?.warehousePalletId)))
|
||||
const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '')
|
||||
.split(',').map((mark) => mark.trim()).filter(Boolean))
|
||||
context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response,
|
||||
candidates.length === relabel.targetPalletIds.length
|
||||
&& relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark))
|
||||
&& candidates.every((value) => !String(value?.cartonMarks || '').trim()),
|
||||
`pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`,
|
||||
OPTIONS)
|
||||
if (candidates.length !== relabel.targetPalletIds.length) return
|
||||
|
||||
const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: candidates.map((value) => id(value.warehousePalletId)),
|
||||
route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo,
|
||||
})
|
||||
const dispatchNo = id(arranged.data?.dispatchNo)
|
||||
context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response,
|
||||
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
|
||||
&& Number(arranged.data?.palletCount) === candidates.length
|
||||
&& Array.isArray(arranged.data?.items)
|
||||
&& arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'),
|
||||
`dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`,
|
||||
OPTIONS)
|
||||
if (!dispatchNo) return
|
||||
Object.assign(context.createdResources.phase4OverseasDispatch, {
|
||||
dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null,
|
||||
relabelNo: relabel.relabelNo, planPalletId: null,
|
||||
})
|
||||
const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '')
|
||||
.split(',').map((mark) => mark.trim()).filter(Boolean))
|
||||
context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response,
|
||||
relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark))
|
||||
&& relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)),
|
||||
`frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch = {
|
||||
marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null,
|
||||
@@ -53,6 +96,12 @@ export async function run(context) {
|
||||
`records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS)
|
||||
if (!success(initialPage.response)) return
|
||||
|
||||
const relabel = context.createdResources?.phase4OverseasRelabel
|
||||
if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') {
|
||||
await runRelabelDispatch(context, relabel)
|
||||
return
|
||||
}
|
||||
|
||||
const fixture = markerFixture(context)
|
||||
if (!fixture) {
|
||||
blocker(context, '取得本轮海外PDA已收货托盘前置',
|
||||
|
||||
@@ -41,7 +41,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
'/api/pda/v1/last-leg/stocktakes',
|
||||
'/api/pda/v1/last-leg/inventory-moves',
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve',
|
||||
'/swap', '/disassemble', '/assemble', '/unbind',
|
||||
'/swap', '/disassemble', '/assemble', '/unbind', '/relabel-pallet-label', '/relabel-disassemble',
|
||||
'/api/pda/v1/last-leg/relabel-tasks',
|
||||
'/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review',
|
||||
]) assert.ok(source.includes(endpoint), endpoint)
|
||||
@@ -50,14 +50,15 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:',
|
||||
'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:',
|
||||
'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map',
|
||||
'const packageCount = 1', 'line.packageCount += additionalPackages', 'oldLabel: item.cartonMark',
|
||||
'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark',
|
||||
'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null',
|
||||
'newLabel: item.fbaCartonMark || item.newSkuCode',
|
||||
"operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:',
|
||||
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) {
|
||||
'P5换标费用冻结后开放PDA作业', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion',
|
||||
'P5库存流水记录换标拆托原因和正负变动', 'phase4OverseasRelabel']) {
|
||||
assert.ok(source.includes(marker), marker)
|
||||
}
|
||||
for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
|
||||
for (const forbidden of ['/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
|
||||
assert.equal(source.includes(forbidden), false, forbidden)
|
||||
}
|
||||
assert.doesNotMatch(source, /fetch\(|https?:\/\//)
|
||||
|
||||
@@ -363,22 +363,17 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
for (const source of orderedCandidates) {
|
||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||
for (const position of source?.positions || []) {
|
||||
if (!id(position?.positionId) || Number(position?.availablePackages) <= 0) continue
|
||||
const packageCount = 1
|
||||
sourceLines.push({ source, position, packageCount })
|
||||
requiredPackages -= packageCount
|
||||
const available = Number(position?.availablePackages)
|
||||
if (!id(position?.positionId) || available <= 0) continue
|
||||
const selected = Math.min(requiredPackages, available)
|
||||
for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) {
|
||||
sourceLines.push({ source, position, packageCount: 1 })
|
||||
}
|
||||
requiredPackages -= selected
|
||||
if (requiredPackages === 0) break
|
||||
}
|
||||
if (requiredPackages === 0) break
|
||||
}
|
||||
for (const line of sourceLines) {
|
||||
if (requiredPackages === 0) break
|
||||
const remainingCapacity = Number(line.position.availablePackages) - line.packageCount
|
||||
if (remainingCapacity <= 0) continue
|
||||
const additionalPackages = Math.min(requiredPackages, remainingCapacity)
|
||||
line.packageCount += additionalPackages
|
||||
requiredPackages -= additionalPackages
|
||||
}
|
||||
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
|
||||
requiredPackages === 0,
|
||||
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
|
||||
@@ -394,6 +389,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||||
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker,
|
||||
idempotencyKey: key(context, 'RELABEL-CREATE'),
|
||||
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
||||
items: sourceLines.map(({ source, position, packageCount }, index) => {
|
||||
const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128)
|
||||
const changesCartonLabel = nonBlank(source.destinationFbaWarehouseCode)
|
||||
@@ -520,15 +516,135 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` }
|
||||
const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody)
|
||||
const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody)
|
||||
context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response,
|
||||
approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED'
|
||||
context.assert(MODULE, 'P5 PDA批准后等待客户确认', approveReplay.response,
|
||||
approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
|
||||
&& approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS)
|
||||
const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
(response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED')
|
||||
context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result,
|
||||
settled.result?.body?.data?.application?.walletStatus === 'SETTLED',
|
||||
`pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS)
|
||||
return success(approved.response) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED'
|
||||
if (approved.data?.applicationStatus !== 'CUSTOMER_CONFIRM_PENDING') return false
|
||||
|
||||
const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'),
|
||||
remark: `${context.marker}-customer-confirmed` }
|
||||
const confirmedResponse = await context.requestOverseas('POST',
|
||||
`${relabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
|
||||
const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null
|
||||
context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse,
|
||||
confirmed?.application?.status === 'READY_FOR_OUTBOUND'
|
||||
&& confirmed?.application?.fulfillmentStatus === 'WAIT_PRE_DISPATCH', trace(confirmedResponse), OPTIONS)
|
||||
if (confirmed?.application?.status !== 'READY_FOR_OUTBOUND') return false
|
||||
|
||||
const operationBase = '/api/pda/v1/last-leg/pallet-operations'
|
||||
const printKeyA = key(context, 'RELABEL-PRINT-A')
|
||||
const printedA = await request(context, token, 'POST', 'P5打印换标新托盘A',
|
||||
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
|
||||
const printedAReplay = await request(context, token, 'POST', 'P5换标新托盘A打印同键重放',
|
||||
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
|
||||
context.assert(MODULE, 'P5新托盘标签幂等且返回可打印HTML', printedAReplay.response,
|
||||
id(printedA.data?.palletId) === id(printedAReplay.data?.palletId)
|
||||
&& printedA.data?.palletCode === printedAReplay.data?.palletCode
|
||||
&& String(printedA.data?.printableHtml || '').includes(printedA.data?.palletCode || '__missing__'),
|
||||
trace(printedAReplay.response), OPTIONS)
|
||||
const printedB = await request(context, token, 'POST', 'P5打印换标新托盘B',
|
||||
`${operationBase}/relabel-pallet-label`, {
|
||||
relabelNo, idempotencyKey: key(context, 'RELABEL-PRINT-B'),
|
||||
})
|
||||
if (!printedA.data?.palletCode || !printedB.data?.palletCode) return false
|
||||
|
||||
const targets = [printedA.data, printedB.data]
|
||||
const beforeBySource = new Map()
|
||||
const movedFacts = []
|
||||
for (const [index, itemDetail] of confirmed.items.entries()) {
|
||||
const sourcePositionId = id(itemDetail?.sourceInventoryPositionId)
|
||||
const sourceLine = sourceLines.find((line) => id(line.position.positionId) === sourcePositionId)
|
||||
const sourcePalletCode = sourceLine?.position?.palletNo
|
||||
if (!sourcePalletCode || !itemDetail?.cartonMark) {
|
||||
blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, confirmedResponse)
|
||||
return false
|
||||
}
|
||||
const sourceBefore = (await resolvePallet(context, token, sourcePalletCode,
|
||||
`P5拆托${index + 1}前刷新来源托盘`)).data
|
||||
if (!sourceBefore) return false
|
||||
if (!beforeBySource.has(sourcePalletCode)) beforeBySource.set(sourcePalletCode, Number(sourceBefore.totalPackages))
|
||||
// A receives one carton, then B receives one and is reused as an occupied merge target.
|
||||
// If the first two cartons share a source, the same source is split across A and B.
|
||||
const targetSeed = index === 0 ? targets[0] : targets[1]
|
||||
const targetBefore = (await resolvePallet(context, token, targetSeed.palletCode,
|
||||
`P5拆托${index + 1}前刷新目标托盘`)).data
|
||||
const reason = index === 0 ? null : `${context.marker}-场景${index + 1}`
|
||||
const body = {
|
||||
idempotencyKey: key(context, `RELABEL-DISASSEMBLE-${index + 1}`), relabelNo,
|
||||
source: { palletCode: sourceBefore.palletCode, expectedVersion: Number(sourceBefore.palletVersion) },
|
||||
target: { palletCode: targetBefore.palletCode, expectedVersion: Number(targetBefore.palletVersion) },
|
||||
cartonMarks: [itemDetail.cartonMark], reason,
|
||||
}
|
||||
const moved = await request(context, token, 'POST', `P5换标箱${index + 1}拆托`,
|
||||
`${operationBase}/relabel-disassemble`, body)
|
||||
const replay = await request(context, token, 'POST', `P5换标箱${index + 1}拆托同键重放`,
|
||||
`${operationBase}/relabel-disassemble`, body)
|
||||
const sourceAfter = operationPallet(moved.data, sourceBefore.palletCode)
|
||||
const targetAfter = operationPallet(moved.data, targetBefore.palletCode)
|
||||
context.assert(MODULE, `P5换标拆托${index + 1}源减目标增且重放稳定`, replay.response,
|
||||
moved.data?.operationType === 'RELABEL_PALLET_DISASSEMBLE' && replay.data?.replayed === true
|
||||
&& Number(sourceAfter?.totalPackages) === Number(sourceBefore.totalPackages) - 1
|
||||
&& Number(targetAfter?.totalPackages) === Number(targetBefore.totalPackages) + 1,
|
||||
`source=${sourceBefore.totalPackages}->${sourceAfter?.totalPackages}, target=${targetBefore.totalPackages}->${targetAfter?.totalPackages}; ${trace(replay.response)}`,
|
||||
OPTIONS)
|
||||
if (!success(moved.response)) return false
|
||||
movedFacts.push({ sourcePalletCode, targetPalletId: id(targetAfter?.palletId),
|
||||
targetPalletCode: targetAfter?.palletCode, cartonMark: itemDetail.cartonMark,
|
||||
fbaCartonMark: itemDetail.fbaCartonMark, balanceId: id(itemDetail.sourceInventoryBalanceId),
|
||||
inboundOrderId: id(itemDetail.sourceInboundOrderId), inboundOrderNo: itemDetail.sourceInboundOrderNo,
|
||||
inboundCode: itemDetail.sourceInboundCode, packages: Number(itemDetail.notifiedPackageCount), reason })
|
||||
}
|
||||
|
||||
for (const [sourcePalletCode, totalBefore] of beforeBySource) {
|
||||
const sourceAfter = (await resolvePallet(context, token, sourcePalletCode, 'P5拆托后核对来源托盘')).data
|
||||
const movedPackages = movedFacts.filter((fact) => fact.sourcePalletCode === sourcePalletCode)
|
||||
.reduce((sum, fact) => sum + fact.packages, 0)
|
||||
context.assert(MODULE, 'P5来源托盘累计箱数按拆托批次减少', null,
|
||||
Number(sourceAfter?.totalPackages) === totalBefore - movedPackages,
|
||||
`pallet=${sourcePalletCode}, before=${totalBefore}, moved=${movedPackages}, after=${sourceAfter?.totalPackages}`, OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, 'P5覆盖新托盘、既有托盘合托和同源分批拆托', null,
|
||||
new Set(movedFacts.map((fact) => fact.targetPalletId)).size === 2
|
||||
&& movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length >= 2
|
||||
&& [...beforeBySource.keys()].some((source) => new Set(movedFacts.filter((fact) => fact.sourcePalletCode === source)
|
||||
.map((fact) => fact.targetPalletId)).size >= 2),
|
||||
`targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS)
|
||||
|
||||
for (const fact of movedFacts) {
|
||||
const positionsResponse = await context.requestOverseas('GET', query(
|
||||
`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`,
|
||||
{ customerId }), undefined)
|
||||
const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data)
|
||||
? positionsResponse.body.data : []
|
||||
const targetPosition = positions.find((position) => id(position?.palletId) === fact.targetPalletId)
|
||||
context.addStep(MODULE, `P5换标箱${fact.cartonMark}保留客户和原入仓归属`, positionsResponse,
|
||||
id(targetPosition?.customerId) === customerId
|
||||
&& id(targetPosition?.inboundOrderId) === fact.inboundOrderId
|
||||
&& targetPosition?.inboundOrderNo === fact.inboundOrderNo
|
||||
&& targetPosition?.inboundCode === fact.inboundCode,
|
||||
`targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS)
|
||||
}
|
||||
const ledgerResponse = await context.requestOverseas('GET', query(
|
||||
'/api/platform/v1/last-leg/inventory/ledger', {
|
||||
customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE',
|
||||
occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19),
|
||||
}))
|
||||
const ledgerRows = success(ledgerResponse) ? records(ledgerResponse.body?.data) : []
|
||||
const relabelLedgers = ledgerRows.filter((row) => movedFacts.some((fact) =>
|
||||
row?.palletNo === fact.sourcePalletCode || row?.palletNo === fact.targetPalletCode))
|
||||
context.addStep(MODULE, 'P5库存流水记录换标拆托原因和正负变动', ledgerResponse,
|
||||
relabelLedgers.some((row) => Number(row?.deltaPackages) < 0)
|
||||
&& relabelLedgers.some((row) => Number(row?.deltaPackages) > 0)
|
||||
&& relabelLedgers.every((row) => String(row?.remark || '').startsWith('换标拆托')),
|
||||
`rows=${relabelLedgers.length}, deltas=${relabelLedgers.map((row) => row.deltaPackages).join(',')}; ${trace(ledgerResponse)}`, OPTIONS)
|
||||
|
||||
context.createdResources.phase4OverseasRelabel = {
|
||||
marker: context.marker, relabelNo, customerId, overseasWarehouseId: id(fixture.overseasWarehouseId),
|
||||
targetPalletIds: [...new Set(movedFacts.map((fact) => fact.targetPalletId))],
|
||||
relabelCartonMarks: movedFacts.map((fact) => fact.fbaCartonMark).filter(nonBlank),
|
||||
sourceCartonMarks: movedFacts.map((fact) => fact.cartonMark), status: 'READY_FOR_OUTBOUND',
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
-- 尾程贴标申请 / 退货换标 dev 增量(MySQL 8.0)
|
||||
-- 目标数据库:baiyue_lastleg
|
||||
-- 依据:2026-09-09 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。
|
||||
-- 说明:脚本可重复执行,不删除或修改业务行;DDL 会隐式提交,请先备份并完整执行。
|
||||
-- 目标数据库:baiyue_lastleg、baiyue_lastlag_warehouse
|
||||
-- 依据:2026-09-10 本地 127.0.0.1 与 dev 8.163.49.32 的 information_schema 只读对比。
|
||||
-- 说明:脚本可重复执行,不修改或删除业务行。
|
||||
-- MySQL DDL 会隐式提交,请先备份并完整执行。
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -88,9 +89,141 @@ DELIMITER ;
|
||||
CALL _dev_lastleg_relabel_return_patch();
|
||||
DROP PROCEDURE IF EXISTS _dev_lastleg_relabel_return_patch;
|
||||
|
||||
-- 执行后只读核验:应分别返回 1、1、1、1。
|
||||
-- 2026-09-10 本地相对 dev 的最近结构差异:换标完成确认、退货仓联系人及派车来源快照。
|
||||
DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE _dev_lastleg_recent_patch()
|
||||
BEGIN
|
||||
-- 旧本地库可能缺少仓库主数据与退货列表查询字段。
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_warehouse' AND table_name='wh_warehouse' AND column_name='internal_code') THEN
|
||||
SET @ddl = 'ALTER TABLE baiyue_warehouse.wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code';
|
||||
PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no') THEN
|
||||
ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT '关联尾程订单号' AFTER lastleg_order_id;
|
||||
END IF;
|
||||
|
||||
-- 非 FBA 去向的换标不要求货件编号。
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='shipment_no' AND is_nullable='NO') THEN
|
||||
ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_code' AND is_nullable='NO') THEN
|
||||
ALTER TABLE ll_relabel_item MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码(组合换标时可为空)';
|
||||
END IF;
|
||||
|
||||
-- 换标 PDA 完工、客户证据确认,以及卡车/自提/快递履约分流。
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填' AFTER contact_phone;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单' AFTER delivery_method;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID' AFTER print_shipping_label;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名' AFTER shipping_label_file_id;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址' AFTER shipping_label_file_name;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态' AFTER shipping_label_file_url;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键' AFTER fulfillment_status;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间' AFTER customer_confirmation_key;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no') THEN
|
||||
ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号' AFTER fulfillment_status;
|
||||
END IF;
|
||||
|
||||
-- 退货换标:保存操作中转仓联系人。
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name') THEN
|
||||
ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照' AFTER overseas_warehouse_id;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone') THEN
|
||||
ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照' AFTER warehouse_contact_name;
|
||||
END IF;
|
||||
-- 仓储转运允许非 FBA 入仓。
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO') THEN
|
||||
ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_shipment_no' AND is_nullable='NO') THEN
|
||||
ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号';
|
||||
END IF;
|
||||
-- 换标进入派车/出库后保留来源业务和箱唛快照。
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks') THEN
|
||||
ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照' AFTER source_stage;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type') THEN
|
||||
ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT '来源业务类型' AFTER carton_marks;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no') THEN
|
||||
ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT '来源业务单号' AFTER source_business_type;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='return_carton_id') THEN
|
||||
ALTER TABLE ll_relabel_item ADD COLUMN return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID' AFTER carton_mark;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_carton_mark') THEN
|
||||
ALTER TABLE ll_relabel_item ADD COLUMN source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照' AFTER return_carton_id;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_carton_mark') THEN
|
||||
ALTER TABLE ll_relabel_item ADD COLUMN new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛' AFTER source_carton_mark;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at') THEN
|
||||
ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT '关联贴标完成时间' AFTER relabel_no;
|
||||
END IF;
|
||||
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL _dev_lastleg_recent_patch();
|
||||
DROP PROCEDURE IF EXISTS _dev_lastleg_recent_patch;
|
||||
|
||||
-- 2026-09-13 换标/合托按托盘维度作业:海外仓物理托盘允许没有库位。
|
||||
-- 只放宽托盘状态操作审计快照,不改变已有带库位的入仓资源预留逻辑。
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE baiyue_lastlag_warehouse;
|
||||
DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE _dev_locationless_pallet_patch()
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 wh_pallet_operation,请先执行海外仓基线';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation'
|
||||
AND column_name='before_location_id' AND is_nullable='NO') THEN
|
||||
ALTER TABLE wh_pallet_operation
|
||||
MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation'
|
||||
AND column_name='after_location_id' AND is_nullable='NO') THEN
|
||||
ALTER TABLE wh_pallet_operation
|
||||
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL _dev_locationless_pallet_patch();
|
||||
DROP PROCEDURE IF EXISTS _dev_locationless_pallet_patch;
|
||||
USE baiyue_lastleg;
|
||||
|
||||
-- 执行后只读核验:所有结果都应为 1。
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('destination_fba_warehouse_code','operation_type','sku_relabel_items_json','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id','source_inbound_order_no','source_inbound_code','source_warehouse_id','source_warehouse_code','source_warehouse_name')) = 11 AS relabel_fields_ready,
|
||||
(SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='shipment_no') AS relabel_shipment_optional_ready,
|
||||
(SELECT is_nullable='YES' FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name='sku_code') AS relabel_sku_optional_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name IN ('delivery_method','print_shipping_label','shipping_label_file_id','shipping_label_file_name','shipping_label_file_url','fulfillment_status','outbound_order_no','customer_confirmation_key','customer_confirmed_at')) = 9 AS relabel_fulfillment_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name IN ('warehouse_contact_name','warehouse_contact_phone')) = 2 AS return_order_contact_fields_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_warehouse_transfer_order' AND column_name IN ('fba_warehouse_code','fba_shipment_no') AND is_nullable='YES') = 2 AS transfer_fba_optional_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_dispatch_item' AND column_name IN ('carton_marks','source_business_type','source_business_no')) = 3 AS dispatch_relabel_source_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_item' AND column_name IN ('return_carton_id','source_carton_mark','new_carton_mark')) = 3 AS return_relabel_binding_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks') = 1 AS outbound_carton_marks_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name='ll_overseas_procurement_outbound') = 1 AS procurement_outbound_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready;
|
||||
(SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='baiyue_lastleg' AND table_name='ll_return_inventory_carton' AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')) = 2 AS return_outbounded_checks_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation' AND column_name IN ('before_location_id','after_location_id') AND is_nullable='YES') = 2 AS locationless_pallet_operation_ready;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 修复海外仓筛选名称、退货箱号和贴标申请列表 503。
|
||||
-- 可重复执行;请使用具备对应库 ALTER 权限的账号执行。
|
||||
|
||||
USE baiyue_warehouse;
|
||||
SET @ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'wh_warehouse'
|
||||
AND column_name = 'internal_code'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'll_return_order'
|
||||
AND column_name = 'lastleg_order_no'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse' AND column_name = 'internal_code') AS warehouse_internal_code_ready,
|
||||
(SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_return_order' AND column_name = 'lastleg_order_no') AS return_lastleg_order_no_ready;
|
||||
@@ -4029,6 +4029,8 @@ USE baiyue_lastleg;
|
||||
-- 2026-09-09 / 036 为派送单补充 BOL 号字段(不改原始 CREATE TABLE)。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='bol_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN bol_no VARCHAR(128) NULL COMMENT ''派送资料BOL号,不作为文件归属标识'''));
|
||||
PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='documents_json'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN documents_json JSON NULL COMMENT ''派送三类资料当前绑定及权威文件元数据快照'' AFTER bol_no'));
|
||||
PREPARE ll_delivery_order_documents_json_stmt FROM @ddl; EXECUTE ll_delivery_order_documents_json_stmt; DEALLOCATE PREPARE ll_delivery_order_documents_json_stmt;
|
||||
SET @ddl = (SELECT IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'),
|
||||
'SELECT 1',
|
||||
@@ -4150,3 +4152,42 @@ SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
|
||||
PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt;
|
||||
|
||||
-- 2026-09-14 / 038 海外尾程按托盘维度作业,无库位托盘的状态流转审计允许空库位。
|
||||
USE baiyue_lastlag_warehouse;
|
||||
ALTER TABLE wh_pallet_operation
|
||||
MODIFY COLUMN before_location_id BIGINT NULL COMMENT '变更前库位ID,无库位托盘为空',
|
||||
MODIFY COLUMN after_location_id BIGINT NULL COMMENT '变更后库位ID,无库位托盘为空';
|
||||
USE baiyue_lastleg;
|
||||
|
||||
-- 2026-09-10 / 036 补齐仓库候选与退货列表查询依赖的字段,修复旧库升级后的 503。
|
||||
USE baiyue_warehouse;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='internal_code'), 'SELECT 1', 'ALTER TABLE wh_warehouse ADD COLUMN internal_code VARCHAR(16) NULL COMMENT ''FBA内部编号,仅FBA仓可配置'' AFTER warehouse_code'));
|
||||
PREPARE warehouse_internal_code_stmt FROM @ddl; EXECUTE warehouse_internal_code_stmt; DEALLOCATE PREPARE warehouse_internal_code_stmt;
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_no VARCHAR(128) NULL COMMENT ''关联尾程订单号'' AFTER lastleg_order_id'));
|
||||
PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_stmt; DEALLOCATE PREPARE return_order_lastleg_no_stmt;
|
||||
|
||||
-- 2026-09-11 / 037 仓储转运 PDA 打托与换标预绑定事实。
|
||||
USE baiyue_lastleg;
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
|
||||
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码',
|
||||
warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID',
|
||||
carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
|
||||
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM',
|
||||
status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
|
||||
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID',
|
||||
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_item(transfer_pallet_id,transfer_item_id), KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id)
|
||||
) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填';
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型',
|
||||
customer_carton_mark VARCHAR(255) NOT NULL COMMENT '客户原箱唛', original_sku VARCHAR(128) NOT NULL COMMENT '旧SKU', new_sku VARCHAR(128) NULL COMMENT '新SKU', quantity INT NOT NULL COMMENT '换标件数',
|
||||
fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id)
|
||||
) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细';
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
-- 本地验收数据:为 17909216591 对应客户建立 3 张已完成入库的一件代发订单。
|
||||
-- 可重复执行;候选数据覆盖订单、入库计划、托盘、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。
|
||||
-- 可重复执行;候选数据覆盖海外实物托盘、订单、入库计划、库存余额/仓位、入库实收、一件代发档案、SKU 与箱唛。
|
||||
USE baiyue_lastlag_warehouse;
|
||||
|
||||
INSERT INTO wh_pallet (
|
||||
id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,unit_price,
|
||||
status,remark,fba_warehouse_code,created_by,created_at,updated_by,updated_at,deleted,version
|
||||
) VALUES
|
||||
(2191000000000909901,0,193000000000200003,NULL,'DS-LAX-PALLET-01','STANDARD',2.0000,0.00,
|
||||
'OCCUPIED','一件代发本地验收固定托盘','ONT8',1000001,NOW(3),1000001,NOW(3),0,0),
|
||||
(2191000000000909902,0,193000000000200003,NULL,'DS-LAX-PALLET-02','STANDARD',2.0000,0.00,
|
||||
'OCCUPIED','一件代发本地验收固定托盘','ABE2',1000001,NOW(3),1000001,NOW(3),0,0),
|
||||
(2191000000000909903,0,193000000000200003,NULL,'DS-LAX-PALLET-03','STANDARD',2.0000,0.00,
|
||||
'OCCUPIED','一件代发本地验收固定托盘','ABE3',1000001,NOW(3),1000001,NOW(3),0,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
warehouse_id=VALUES(warehouse_id),pallet_code=VALUES(pallet_code),pallet_type=VALUES(pallet_type),
|
||||
status=VALUES(status),fba_warehouse_code=VALUES(fba_warehouse_code),deleted=0,updated_at=NOW(3);
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS _tmp_dropship_fixture;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
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 sourceReport = process.env.OVERSEAS_ACCEPTANCE_SOURCE
|
||||
const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO
|
||||
const directRelabelNo = process.env.OVERSEAS_RELABEL_NO
|
||||
const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json'
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
||||
const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
const required = ['ADMIN_TEST_USERNAME', 'ADMIN_TEST_PASSWORD', 'PDA_TEST_USERNAME', 'PDA_TEST_PASSWORD']
|
||||
for (const name of required) if (!process.env[name]) throw new Error(`Missing ${name}`)
|
||||
if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id')
|
||||
|
||||
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) }
|
||||
async function api(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, { method,
|
||||
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
|
||||
}
|
||||
|
||||
const source = sourceReport ? JSON.parse(await readFile(sourceReport, 'utf8')) : null
|
||||
const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TARGETS')
|
||||
|| { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' }
|
||||
assert(scenario?.dispatchNo, 'Dispatch not found in source report')
|
||||
const dispatchNo = scenario.dispatchNo
|
||||
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })
|
||||
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD })
|
||||
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` })
|
||||
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
const platformToken = platformLogin.overseasAccessToken
|
||||
const merchantToken = merchantLogin.overseasAccessToken
|
||||
const pdaToken = pdaLogin.overseasAccessToken
|
||||
|
||||
await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, {
|
||||
vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000',
|
||||
carrierName: '尾程验收车队', route: `${marker}-ROUTE`,
|
||||
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
})
|
||||
const transfer = await api(overseas, merchantToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, {
|
||||
idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(),
|
||||
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队',
|
||||
pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: 'MEDIUM',
|
||||
driverName: '尾程验收司机', driverPhone: '19900000000', vehiclePlate: `UAT-${marker.slice(-8)}`,
|
||||
appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费',
|
||||
direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }],
|
||||
})
|
||||
assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery')
|
||||
|
||||
let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
|
||||
assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items')
|
||||
for (const [itemIndex, item] of task.items.entries()) {
|
||||
let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
|
||||
for (const [positionIndex, position] of item.positions.entries()) {
|
||||
if (remaining <= 0) break
|
||||
const packages = Math.min(remaining, Number(position.availablePackages))
|
||||
if (packages <= 0) continue
|
||||
await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, {
|
||||
positionId: id(position.id), packages, productQuantity: packages,
|
||||
idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker,
|
||||
})
|
||||
remaining -= packages
|
||||
}
|
||||
assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`)
|
||||
}
|
||||
const completed = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') })
|
||||
assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART')
|
||||
const departed = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker })
|
||||
assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED')
|
||||
|
||||
const outbound = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`)
|
||||
assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED')
|
||||
let delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`)
|
||||
const delivering = await api(overseas, platformToken, 'PUT',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, {
|
||||
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
|
||||
})
|
||||
assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING')
|
||||
delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`)
|
||||
const delivered = await api(overseas, platformToken, 'POST',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, {
|
||||
status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker,
|
||||
})
|
||||
assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED')
|
||||
|
||||
const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo,
|
||||
dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo,
|
||||
outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo,
|
||||
deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages,
|
||||
passed: true, finishedAt: new Date().toISOString() }
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
@@ -0,0 +1,193 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
|
||||
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 platformUser = process.env.ADMIN_TEST_USERNAME
|
||||
const platformPassword = process.env.ADMIN_TEST_PASSWORD
|
||||
const pdaUser = process.env.PDA_TEST_USERNAME
|
||||
const pdaPassword = process.env.PDA_TEST_PASSWORD
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
||||
const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID
|
||||
const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json'
|
||||
const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
|
||||
for (const [name, value] of Object.entries({ platformUser, platformPassword, pdaUser, pdaPassword, customerId, warehouseId })) {
|
||||
if (!value) throw new Error(`Missing environment variable: ${name}`)
|
||||
}
|
||||
|
||||
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false }
|
||||
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
async function api(base, token, method, path, body, headers = {}) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers },
|
||||
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 poll(action, predicate, attempts = 40) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
const value = await action()
|
||||
if (predicate(value)) return value
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
throw new Error('Polling timed out')
|
||||
}
|
||||
function assert(condition, message) { if (!condition) throw new Error(message) }
|
||||
|
||||
const platformLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
|
||||
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
|
||||
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` })
|
||||
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
const platformToken = platformLogin.overseasAccessToken
|
||||
const merchantToken = merchantLogin.overseasAccessToken
|
||||
const pdaToken = pdaLogin.overseasAccessToken
|
||||
assert(platformToken && merchantToken && pdaToken, 'Missing overseas token')
|
||||
|
||||
const candidates = await api(overseas, merchantToken, 'GET',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`)
|
||||
const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-')
|
||||
&& Number(item.availablePackages) >= 5
|
||||
&& item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))
|
||||
assert(source, 'No overseas inventory source with at least five available cartons')
|
||||
const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5)
|
||||
|
||||
async function resolvePallet(palletCode) {
|
||||
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
||||
}
|
||||
|
||||
async function createAndApprove(name, cartonCount) {
|
||||
const suffix = `${name}-${Date.now()}`
|
||||
const items = Array.from({ length: cartonCount }, (_, index) => ({
|
||||
sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId),
|
||||
sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode,
|
||||
productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1,
|
||||
shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128),
|
||||
labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`,
|
||||
fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
||||
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode,
|
||||
newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }],
|
||||
}))
|
||||
const created = await api(overseas, platformToken, 'POST',
|
||||
`/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, {
|
||||
customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId,
|
||||
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||||
contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01,
|
||||
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK',
|
||||
printShippingLabel: false, items,
|
||||
})
|
||||
const relabelNo = created.application.relabelNo
|
||||
await poll(() => api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
|
||||
(detail) => detail.application.walletStatus === 'FROZEN')
|
||||
let detail = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) })
|
||||
for (const [index, item] of detail.items.entries()) {
|
||||
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
|
||||
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark,
|
||||
expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
|
||||
})
|
||||
}
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`)
|
||||
const photo = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form)
|
||||
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
|
||||
photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
|
||||
})
|
||||
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, {
|
||||
decision: 'APPROVE', expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(`${name}-APPROVE`), remark: marker,
|
||||
})
|
||||
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
|
||||
const confirmed = await api(overseas, merchantToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
|
||||
idempotencyKey: key(`${name}-CONFIRM`), remark: marker,
|
||||
})
|
||||
assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`)
|
||||
return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) }
|
||||
}
|
||||
|
||||
async function printPallet(relabelNo, suffix) {
|
||||
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
|
||||
relabelNo, idempotencyKey: key(`PRINT-${suffix}`),
|
||||
})
|
||||
}
|
||||
async function moveCarton(scenario, item, target, index) {
|
||||
const sourcePallet = await resolvePallet(sourcePosition.palletNo)
|
||||
const targetPallet = await resolvePallet(target.palletCode)
|
||||
const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
|
||||
idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo,
|
||||
source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) },
|
||||
target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) },
|
||||
cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`,
|
||||
})
|
||||
assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type')
|
||||
return result
|
||||
}
|
||||
|
||||
const fresh = await createAndApprove('NEW-PALLET', 1)
|
||||
const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED')
|
||||
const printReplay = await printPallet(fresh.relabelNo, 'SHARED')
|
||||
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
|
||||
await moveCarton(fresh, fresh.items[0], sharedTarget, 0)
|
||||
fresh.targets = [sharedTarget]
|
||||
|
||||
const merged = await createAndApprove('MERGE-EXISTING', 1)
|
||||
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
||||
await moveCarton(merged, merged.items[0], sharedTarget, 0)
|
||||
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
||||
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
|
||||
merged.targets = [sharedTarget]
|
||||
|
||||
const split = await createAndApprove('SPLIT-TWO-TARGETS', 2)
|
||||
const splitA = await printPallet(split.relabelNo, 'SPLIT-A')
|
||||
const splitB = await printPallet(split.relabelNo, 'SPLIT-B')
|
||||
await moveCarton(split, split.items[0], splitA, 0)
|
||||
await moveCarton(split, split.items[1], splitB, 1)
|
||||
split.targets = [splitA, splitB]
|
||||
|
||||
for (const scenario of [fresh, merged, split]) {
|
||||
const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
||||
scenario.sourceAfter = sourceAfter
|
||||
scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode }))
|
||||
const candidateRows = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`)
|
||||
const targetIds = new Set(scenario.targets.map((target) => id(target.palletId)))
|
||||
const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId)))
|
||||
const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean))
|
||||
assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`)
|
||||
assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`)
|
||||
assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`)
|
||||
const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
||||
planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`,
|
||||
relabelNo: scenario.relabelNo,
|
||||
})
|
||||
scenario.dispatchNo = dispatch.dispatchNo
|
||||
scenario.dispatchStatus = dispatch.status
|
||||
scenario.relabelCartonMarks = marks
|
||||
delete scenario.items
|
||||
delete scenario.targets
|
||||
}
|
||||
|
||||
const ledger = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`)
|
||||
const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker))
|
||||
assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger')
|
||||
assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger')
|
||||
assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason')
|
||||
report.scenarios = [fresh, merged, split]
|
||||
report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) }
|
||||
report.passed = true
|
||||
report.finishedAt = new Date().toISOString()
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
Reference in New Issue
Block a user