fix(acceptance): reuse initialized pallet fixtures

This commit is contained in:
2026-09-10 20:47:15 +08:00
parent 69e80a736e
commit 47a70406f6
12 changed files with 65 additions and 95 deletions
@@ -6988,7 +6988,8 @@ ON DUPLICATE KEY UPDATE
capacity_cbm = VALUES(capacity_cbm), capacity_cbm = VALUES(capacity_cbm),
weight_kg = VALUES(weight_kg), weight_kg = VALUES(weight_kg),
color = VALUES(color), color = VALUES(color),
remark = VALUES(remark); remark = VALUES(remark),
deleted = 0;
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed; DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER // DELIMITER //
@@ -7010,6 +7011,14 @@ BEGIN
) THEN ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓木托盘、塑料托盘初始化不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓木托盘、塑料托盘初始化不完整';
END IF; END IF;
IF (SELECT COUNT(*)
FROM wh_pallet
WHERE tenant_id = 0
AND deleted = 0
AND ((warehouse_id = 193000000000200001 AND pallet_code IN ('TP-001','YW-TP-002'))
OR (warehouse_id = 193000000000200002 AND pallet_code IN ('TP-002','DG-TP-002')))) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓固定可复用托盘编码初始化不完整';
END IF;
END// END//
DELIMITER ; DELIMITER ;
CALL assert_domestic_pallet_seed(); CALL assert_domestic_pallet_seed();
+1 -36
View File
@@ -1317,7 +1317,7 @@ export class AcceptanceRun {
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount', 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount',
'phase3LoadingIncrementalDetailCount', 'orderProfile', 'measurementPalletOverride'] 'phase3LoadingIncrementalDetailCount', 'orderProfile']
const createdSnapshot = new Map(createdKeys.map((key) => [key, { const createdSnapshot = new Map(createdKeys.map((key) => [key, {
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
}])) }]))
@@ -1362,9 +1362,6 @@ export class AcceptanceRun {
: { forced: false, disabled: [] } : { forced: false, disabled: [] }
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
try { try {
if (requireSharedOverflow) {
this.context.measurementPalletOverride = await this.createPhase3FreshMeasurementPallet(warehouseId)
}
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
} finally { } finally {
@@ -1397,7 +1394,6 @@ export class AcceptanceRun {
const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC` const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC`
this.marker = incrementalMarker this.marker = incrementalMarker
this.context.phase3LoadingRequireSharedOverflow = false this.context.phase3LoadingRequireSharedOverflow = false
delete this.context.measurementPalletOverride
this.context.phase3LoadingIncrementalPackageCount = 2 this.context.phase3LoadingIncrementalPackageCount = 2
this.context.phase3LoadingIncrementalDetailCount = 4 this.context.phase3LoadingIncrementalDetailCount = 4
// orders.mjs derives the actual SKU/package shape from waitSplitVariant. Override that // orders.mjs derives the actual SKU/package shape from waitSplitVariant. Override that
@@ -1609,37 +1605,6 @@ export class AcceptanceRun {
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
} }
async createPhase3FreshMeasurementPallet(warehouseId) {
const module = 'phase3-loading-fixture'
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
const payload = {
warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25,
color: 'ORANGE', remark: `${this.marker}-inventory-snapshot-a0`,
}
const createResponse = await this.request('POST', '/api/platform/v1/pallets?tenantId=0', payload)
const palletId = String(this.apiSuccess(module, '创建本轮库存快照专用称重托盘', createResponse,
`warehouseId=${warehouseId}`, options) || '')
if (!palletId) return null
const detailResponse = await this.request('GET',
`/api/platform/v1/pallets/${encodeURIComponent(palletId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
const pallet = this.apiSuccess(module, '回读本轮库存快照专用称重托盘', detailResponse,
`palletId=${palletId}`, options)
const valid = String(pallet?.id || '') === palletId
&& String(pallet?.warehouseId || '') === String(warehouseId)
&& Boolean(String(pallet?.palletCode || '').trim())
this.assert(module, '本轮专用称重托盘尚未复用历史占用身份', detailResponse, valid,
`palletId=${palletId}, palletCode=${pallet?.palletCode || 'missing'}`, options)
if (!valid) return null
const fixture = {
id: palletId, code: String(pallet.palletCode), warehouseId: String(warehouseId),
marker: this.marker, retainedReason: '测量审核后成为正式库存占用,保留为开发验收证据',
}
this.createdResources.phase3LoadingFreshMeasurementPallet = fixture
return fixture
}
async listAvailableLocationsForA0(warehouseId, module, options) { async listAvailableLocationsForA0(warehouseId, module, options) {
const locations = [] const locations = []
const seen = new Set() const seen = new Set()
+1 -1
View File
@@ -140,7 +140,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。 其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 通过平台托盘 API 创建本轮专用称重托盘,再交给通用测量模块;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库或调用内部 Warehouse 接口。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。批量待分柜入口仍只复用 `TP-001`/`YW-TP-002`,不创建托盘。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 与批量待分柜入口都只随机复用初始化的 `TP-001`/`YW-TP-002`,任一缺失即报告部署阻断,不调用托盘新增接口;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库、创建库位或调用内部 Warehouse 接口。模拟柜的 `channelGroupCode` 统一由 `smart-loading-channel-group.mjs` 根据订单渠道映射。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
动态增量还会读取计划明细中的服务端 `totalPackageCount/remainingPackageCount`,断言 动态增量还会读取计划明细中的服务端 `totalPackageCount/remainingPackageCount`,断言
`remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查 `remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查
@@ -30,13 +30,6 @@ function validPdf(response) {
async function reusablePallets(context, warehouseId) { async function reusablePallets(context, warehouseId) {
const cacheKey = asId(warehouseId) const cacheKey = asId(warehouseId)
const override = context.context?.measurementPalletOverride
if (asId(override?.warehouseId) === cacheKey && asId(override?.id)
&& String(override?.code || '').trim()) {
context.assert(MODULE, '使用编排层提供的本轮专用称重托盘', null, true,
`warehouseId=${warehouseId}, palletId=${asId(override.id)}`, OPTIONS)
return [{ id: asId(override.id), code: String(override.code) }]
}
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}` const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}`
if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey) if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey)
@@ -64,29 +57,11 @@ async function reusablePallets(context, warehouseId) {
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
&& String(item?.palletCode || '') === palletCode && asId(item?.id)) && String(item?.palletCode || '') === palletCode && asId(item?.id))
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true, context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=${exact ? 'not-needed' : 'create-marker-owned'}`, OPTIONS) `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS)
if (!exact) { if (!exact) {
// A newly selected development warehouse may not have the historical fixture pallets. context.assert(MODULE, '固定称重托盘主数据完整', response, false,
// Create one marker-owned pallet through the public platform API rather than coupling `warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS)
// measurement acceptance to another warehouse's seed data. return null
const createResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', {
warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25,
color: 'ORANGE', remark: `${context.marker}-measurement-fixture`,
})
const createdId = asId(context.apiSuccess(MODULE, '创建本轮称重托盘', createResponse,
`warehouseId=${warehouseId}`, OPTIONS))
if (!createdId) return null
const detailResponse = await context.request('GET',
`/api/platform/v1/pallets/${encodeURIComponent(createdId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
const created = context.apiSuccess(MODULE, '回读本轮称重托盘', detailResponse,
`warehouseId=${warehouseId}, palletId=${createdId}`, OPTIONS)
const valid = asId(created?.id) === createdId && asId(created?.warehouseId) === cacheKey
&& String(created?.palletCode || '').trim()
context.assert(MODULE, '新建称重托盘归属当前仓库且编码完整', detailResponse, valid,
`warehouseId=${warehouseId}, palletId=${createdId}, palletCode=${created?.palletCode || 'missing'}`, OPTIONS)
if (!valid) return null
pallets.push({ id: createdId, code: String(created.palletCode) })
break
} }
pallets.push({ id: asId(exact.id), code: palletCode }) pallets.push({ id: asId(exact.id), code: palletCode })
} }
@@ -28,14 +28,14 @@ test('随机线路只固定义乌仓并从客户真实可计价候选选择', ()
assert.doesNotMatch(runner, /channel-name|channel-code/) assert.doesNotMatch(runner, /channel-name|channel-code/)
}) })
test('称重优先复用固定托盘并允许为无基线仓创建本轮托盘', () => { test('称重只随机复用固定托盘且不创建新托盘', () => {
assert.match(measurement, /REUSABLE_PALLET_CODES = Object\.freeze\(\['TP-001', 'YW-TP-002'\]\)/) assert.match(measurement, /REUSABLE_PALLET_CODES = Object\.freeze\(\['TP-001', 'YW-TP-002'\]\)/)
assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/) assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/)
assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/) assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/)
assert.match(measurement, /随机复用原有称重托盘/) assert.match(measurement, /随机复用原有称重托盘/)
assert.match(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets\?tenantId=0'/) assert.match(measurement, /固定称重托盘主数据完整/)
assert.match(measurement, /创建本轮称重托盘/) assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets/)
assert.match(measurement, /measurement-fixture/) assert.doesNotMatch(acceptance, /createPhase3FreshMeasurementPallet/)
}) })
test('称重脚本不新增库位且容量不足由仓库服务复用 A0 公共位', () => { test('称重脚本不新增库位且容量不足由仓库服务复用 A0 公共位', () => {
@@ -3,6 +3,7 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-pr
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
const MODULE = 'phase3-container-loading' const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -208,7 +209,7 @@ export async function run(context) {
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) `channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
const simulationResponse = await request(context, 'POST', CONTAINERS, { const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType, channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
}) })
const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse, const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse,
@@ -216,7 +217,8 @@ export async function run(context) {
const containerId = id(simulation?.id) const containerId = id(simulation?.id)
context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse, context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse,
Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null
&& simulation?.oceanBookingId == null && simulation?.channelCode === channelCode && simulation?.oceanBookingId == null && simulation?.channelGroupCode === channelGroup(channelCode)
&& simulation?.channelCode == null
&& id(simulation?.warehouseId) === warehouseId, && id(simulation?.warehouseId) === warehouseId,
`containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS) `containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS)
if (!containerId) return if (!containerId) return
@@ -240,7 +242,8 @@ export async function run(context) {
} else { } else {
const foreignChannel = channelIsolation.pair.foreign const foreignChannel = channelIsolation.pair.foreign
const foreignContainerResponse = await request(context, 'POST', CONTAINERS, { const foreignContainerResponse = await request(context, 'POST', CONTAINERS, {
channelCode: foreignChannel.channelCode, warehouseId, containerType: primaryRequirement.containerType, channelGroupCode: channelGroup(foreignChannel.channelCode), warehouseId,
containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
}) })
const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS) const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS)
@@ -288,9 +291,10 @@ export async function run(context) {
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse, context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode (!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode) || containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
&& containerDetail?.channelCode === channelCode && containerDetail?.channelGroupCode === channelGroup(channelCode)
&& containerDetail?.channelCode == null
&& ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus), && ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus),
`planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS) `planStatus=${containerDetail?.planStatus || 'missing'}, channelGroup=${containerDetail?.channelGroupCode || 'missing'}`, OPTIONS)
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo) const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
@@ -517,7 +521,7 @@ export async function run(context) {
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS) `containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, { const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType, channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
}) })
const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS) const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS)
@@ -1,6 +1,7 @@
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs' import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
const MODULE = 'phase3-loading' const MODULE = 'phase3-loading'
@@ -135,20 +136,6 @@ function inventorySignature(snapshot) {
}))) })))
} }
function channelGroup(channelCode) {
const value = String(channelCode || '').toUpperCase()
if (value.startsWith('EXX_')) return 'EXX'
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
if (value.startsWith('HEDE_')) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
if (value === 'OA_NEW_YORK_TRUCK') return value
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
function operationsSignature(operations) { function operationsSignature(operations) {
return JSON.stringify((operations || []).map((operation) => ({ return JSON.stringify((operations || []).map((operation) => ({
id: id(operation?.id), operationId: operation?.operationId || null, id: id(operation?.id), operationId: operation?.operationId || null,
@@ -226,7 +213,7 @@ export async function run(context) {
maxWeightKg: 28000 }], maxWeightKg: 28000 }],
} }
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', { const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HC', channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 76, maxWeightKg: 28000,
}) })
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS) const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
@@ -10,6 +10,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
import { ensureInitialCalculated } from './inbound-fees.mjs' import { ensureInitialCalculated } from './inbound-fees.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
@@ -186,7 +187,8 @@ async function loadOneFragment(context, state) {
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
if (!carrier?.id) return null if (!carrier?.id) return null
const simulationResponse = await request(context, 'POST', CONTAINERS, { const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, channelGroupCode: channelGroup(state.channelCode), warehouseId,
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
}) })
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS) const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id) const containerId = id(simulation?.id)
@@ -434,7 +434,9 @@ test('container loading permits manual cross-channel allocation and keeps stable
'换柜号后按稳定计划身份回查 PDA 任务', '换柜号后按稳定计划身份回查 PDA 任务',
'换柜号不改变任务计划柜次柜次号和库存快照身份', '换柜号不改变任务计划柜次柜次号和库存快照身份',
]) ])
assert.match(value, /containerDetail\?\.channelCode === channelCode/) assert.match(value, /simulation\?\.channelGroupCode === channelGroup\(channelCode\)/)
assert.match(value, /simulation\?\.channelCode == null/)
assert.match(value, /containerDetail\?\.channelGroupCode === channelGroup\(channelCode\)/)
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/) assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/) assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/) assert.match(value, /pdaIdentityStable: true/)
@@ -0,0 +1,14 @@
export function channelGroup(channelCode) {
const value = String(channelCode || '').trim().toUpperCase()
if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX'
if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX'
if (value.startsWith('HEDE_') || ['HDHK', 'HDHWK', 'HDHP'].includes(value)) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL'
|| ['PCHK', 'PCHWK', 'PCKK', 'PCHP'].includes(value)) return 'OA_LA'
if (value.startsWith('OA_CHICAGO_') || value === 'CPHT') return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_') || value === 'HPHK') return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_') || value === 'SPHK') return 'OA_SAVANNAH'
if (['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'].includes(value)) return 'OA_NEW_YORK_TRUCK'
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
@@ -16,7 +16,8 @@ ON DUPLICATE KEY UPDATE
capacity_cbm=VALUES(capacity_cbm), capacity_cbm=VALUES(capacity_cbm),
weight_kg=VALUES(weight_kg), weight_kg=VALUES(weight_kg),
color=VALUES(color), color=VALUES(color),
remark=VALUES(remark); remark=VALUES(remark),
deleted=0;
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed; DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER // DELIMITER //
@@ -38,6 +39,14 @@ BEGIN
) THEN ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='国内作业仓木托盘、塑料托盘初始化不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='国内作业仓木托盘、塑料托盘初始化不完整';
END IF; END IF;
IF (SELECT COUNT(*)
FROM wh_pallet
WHERE tenant_id=0
AND deleted=0
AND ((warehouse_id=190000000000000001 AND pallet_code IN ('TP-001','YW-TP-002'))
OR (warehouse_id=190000000000000002 AND pallet_code IN ('TP-002','DG-TP-002')))) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='国内作业仓固定可复用托盘编码初始化不完整';
END IF;
END// END//
DELIMITER ; DELIMITER ;
CALL assert_domestic_pallet_seed(); CALL assert_domestic_pallet_seed();
@@ -27,6 +27,7 @@ class InitialWarehouseResourceSeedSqlContractTest {
} }
private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) { private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) {
String compactSql = sql.replaceAll("\\s+", "");
int locationSeedStart = sql.indexOf("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201"); int locationSeedStart = sql.indexOf("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201");
int locationSeedEnd = sql.indexOf("DROP PROCEDURE IF EXISTS assert_yiwu_location_seed", locationSeedStart); int locationSeedEnd = sql.indexOf("DROP PROCEDURE IF EXISTS assert_yiwu_location_seed", locationSeedStart);
String locationSeed = sql.substring(locationSeedStart, locationSeedEnd); String locationSeed = sql.substring(locationSeedStart, locationSeedEnd);
@@ -37,6 +38,7 @@ class InitialWarehouseResourceSeedSqlContractTest {
.contains("0," + dongguanId + ",'DG-TP-002','木托盘'") .contains("0," + dongguanId + ",'DG-TP-002','木托盘'")
.contains("COUNT(DISTINCT pallet.pallet_type)") .contains("COUNT(DISTINCT pallet.pallet_type)")
.contains("国内作业仓木托盘、塑料托盘初始化不完整") .contains("国内作业仓木托盘、塑料托盘初始化不完整")
.contains("固定可复用托盘编码初始化不完整")
.doesNotContain("'胶托盘'") .doesNotContain("'胶托盘'")
.contains("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201") .contains("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201")
.contains("ELSE 203 + zones.zone_index * 15 + slots.slot_number") .contains("ELSE 203 + zones.zone_index * 15 + slots.slot_number")
@@ -55,6 +57,7 @@ class InitialWarehouseResourceSeedSqlContractTest {
.contains("'AVAILABLE'") .contains("'AVAILABLE'")
.doesNotContain("INSERT INTO wh_location_reservation") .doesNotContain("INSERT INTO wh_location_reservation")
.doesNotContain("INSERT INTO wh_location_occupancy"); .doesNotContain("INSERT INTO wh_location_occupancy");
assertThat(compactSql).contains("remark=VALUES(remark),deleted=0;");
assertThat(locationSeed) assertThat(locationSeed)
.doesNotContain("status='AVAILABLE'") .doesNotContain("status='AVAILABLE'")
.doesNotContain("status = 'AVAILABLE'") .doesNotContain("status = 'AVAILABLE'")