From 05e3aa4e8d61d052b7089031c9e6245cea7f7339 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:27:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 10 +- scripts/admin-test-env-modules/README.md | 2 + .../phase4-overseas-pda-contract.test.mjs | 40 +++- .../phase4-overseas-pda.mjs | 192 ++++++++---------- .../db/phase1-warehouse-schema-migration.sql | 5 +- .../main/resources/db/warehouse-partner.sql | 4 +- .../WarehouseSchemaContractTest.java | 11 +- 7 files changed, 143 insertions(+), 121 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 00b8d3452..6f43de038 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -890,6 +890,7 @@ CREATE TABLE IF NOT EXISTS wh_partner ( data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称', partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', @@ -901,7 +902,8 @@ CREATE TABLE IF NOT EXISTS wh_partner ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), KEY idx_wh_partner_tenant_status(tenant_id,status,deleted), - CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) + CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')), + CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')) ) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', @@ -1872,7 +1874,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); -CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code'); CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); @@ -1888,6 +1891,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); +UPDATE wh_partner SET carrier_code = 'OTHER' + WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL; -- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); @@ -14748,6 +14753,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'target_business_id', '目标子入仓单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'tenant_id', '仓储公共资源域,固定为0'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'address_line', '详细地址'); +CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code', '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'city', '城市'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_email', '联系邮箱'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_name', '联系人姓名'); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4a6955f45..d17fe3a6e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -86,6 +86,8 @@ export async function run(context) { 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 +海外 PDA 收货统一扫描冻结箱码:`FBA` 与 `OVERSEAS` 都必须由 `POST /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve` 返回 HTTP 200、业务码 `200` 和当前柜唯一 `inboundCode`,不展示、扫描或提交 SKU。仅实收阶段分流:FBA 使用不含 `skuCode` 的 `/current/receipts/bulk`,逐箱接口必须精确返回 `A0409 FBA destination must use bulk count`;OVERSEAS 使用 `/current/receipts/carton`,bulk 必须精确返回 `A0409 OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden`。实机旧行为反证为 requestId `5e925a3d-8184-41e1-b0d2-f4e1aae86639`、traceId `62fdf603b65fd7f43ec452bb8a6ca185`。跨区广播消费只保留观察证据,不再单独形成 deployment blocker;实际 `PICKUP_READY` 候选门禁仍必须通过。 + ```powershell node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 0f4bf940b..bc63c2007 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -22,7 +22,7 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', - '/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve', + '/current/cartons/resolve', '/current/pallets/resolve', '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) { assert.ok(source.includes(token), token) @@ -41,8 +41,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/) - assert.match(source, /packageCount = Number\(resolved\.data\?\.pendingPackages \|\| 0\)/) - assert.match(source, /if \(packageCount <= 0\) continue/) + assert.match(source, /packageCount = Number\(inbound\.pendingPackages \|\| 0\)/) + assert.match(source, /if \(packageCount <= 0\) return true/) assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/) assert.match(source, /模式硬拒绝/) assert.match(source, /current返回404正常空态/) @@ -57,11 +57,14 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) assert.match(source, /const cartonCandidates =/) - assert.match(source, /candidateExcluded = notFound\(response\)/) - assert.match(source, /if \(candidateExcluded\) continue/) - assert.match(source, /const fixturesConserved = cartonFixtures\.length === expectedPending/) + assert.doesNotMatch(source, /current\/skus\/resolve/) + assert.doesNotMatch(source, /skuCode:/) + assert.match(source, /FBA和OVERSEAS箱码resolve都必须HTTP 200\/code 200并返回当前柜唯一入仓码/) + assert.match(source, /const matches = inbounds\.filter/) + assert.match(source, /matches\.length === 1/) + assert.match(source, /const fixturesConserved = overseasFixtures\.length === expectedPending/) assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/) - assert.match(source, /for \(const \[index, fixture\] of cartonFixtures\.entries\(\)\)/) + assert.match(source, /for \(const \[index, fixture\] of overseasFixtures\.entries\(\)\)/) assert.doesNotMatch(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) @@ -70,7 +73,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/) assert.match(source, /awaitProducerConsumption\(context, allProducers\)/) assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) - assert.match(source, /跨区消费生成尾程订单/) + assert.match(source, /跨区广播消费观察/) + assert.match(source, /OBSERVATION_OPTIONS = \{ scenario: SCENARIO, deploymentBlocker: false \}/) + assert.doesNotMatch(source, /blockerGap\(context, '跨区消费生产矩阵'/) assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/) assert.match(source, /missingPickupReady=/) assert.match(source, /item\?\.shippingReady === false/) @@ -82,6 +87,25 @@ test('PDA module covers the container-number receiving contract and excludes out assert.doesNotMatch(source, /awaitProducerCandidates/) }) +test('real-device legacy FBA carton rejection is retained as regression evidence', () => { + const evidence = { + http: 200, + code: 'A0409', + message: 'FBA destination must use bulk count', + requestId: '5e925a3d-8184-41e1-b0d2-f4e1aae86639', + traceId: '62fdf603b65fd7f43ec452bb8a6ca185', + } + assert.deepEqual(evidence, { + http: 200, + code: 'A0409', + message: 'FBA destination must use bulk count', + requestId: '5e925a3d-8184-41e1-b0d2-f4e1aae86639', + traceId: '62fdf603b65fd7f43ec452bb8a6ca185', + }) + assert.match(source, /a0409Message\(response, \/\^FBA destination must use bulk count\$\//) + assert.match(source, /a0409Message\(response, \/\^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden\$\//) +}) + test('PDA receiving lifecycle is ordered from candidate claim through photos, mode-specific receipts, pallet and complete', () => { const orderedTokens = [ "'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'", diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index d26d113f7..1306be5c2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -161,7 +161,7 @@ async function createPallet(context, inbound, index) { return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, retained: true } : null } -async function verifyWarehouseQueries(context, token, inboundCode, pallet) { +async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) { const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存', '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode }) context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response, @@ -170,6 +170,11 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet) { && inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId && id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0), `items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS) + const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : [] + context.assert(MODULE, `${destinationType}实收形成服务端SKU库存账`, inventory.response, + success(inventory.response) && inventoryItems.length > 0 + && inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0), + `skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS) const location = await pda(context, token, 'POST', '完成入库后扫码查询库位', '/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode }) @@ -245,9 +250,9 @@ async function awaitProducerConsumption(context, producers) { } for (const containerNo of expected) { const response = lastResponses.get(containerNo) || null - context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response, + context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response, consumed.has(containerNo), - `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS) + `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS) } return expected.every((containerNo) => consumed.has(containerNo)) } @@ -288,110 +293,76 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer return ready } -function skuFixtures(context, producer) { - const frozenLines = Array.isArray(producer?.skuLines) - ? producer.skuLines - .map((line) => ({ skuCode: id(line?.skuCode), packageCount: Number(line?.packageCount) })) - .filter((line) => nonBlank(line.skuCode) && Number.isInteger(line.packageCount) && line.packageCount > 0) - : [] - if (frozenLines.length) return frozenLines - const frozen = Array.isArray(producer?.skuCodes) - ? producer.skuCodes.map(id).filter(nonBlank) - : [] - if (frozen.length) return [...new Set(frozen)].map((skuCode) => ({ skuCode, packageCount: 1 })) - const boxes = context.createdResources?.orders?.boxTypes - const orchestration = context.createdResources?.orchestrationFixtures || {} - const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, - orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) - return Array.isArray(boxes) - ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] - .map((skuCode) => ({ skuCode, packageCount: 1 })) - : [] -} - -async function receiveFba(context, token, inbound, pallet, producer) { - const skus = skuFixtures(context, producer) - if (!skus.length) { - blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') - return false - } - let accepted = 0 - for (const [index, sku] of skus.entries()) { - const { skuCode } = sku - const resolvedResponse = await context.requestOverseas('POST', - '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) - const resolvable = success(resolvedResponse) || notFound(resolvedResponse) - context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) - const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } - if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue - const packageCount = Number(resolved.data?.pendingPackages || 0) - if (packageCount <= 0) continue - const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, - packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } - const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) - if (!success(first.response)) return false - accepted += Number(first.data?.acceptedPackages || 0) - const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) - context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response, - success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) - await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', - { ...body, packageCount: packageCount + 1 }, conflict) - await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', - { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) - } - if (accepted <= 0) { - blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) - return false - } - return true -} - -async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) { +async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) { const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) .map(id).filter(nonBlank))] if (!cartonCandidates.length) { - blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号') - return false + blockerGap(context, '统一箱码解析链', '缺少本轮PDA任务冻结箱码,FBA和OVERSEAS均不能使用伪造箱号') + return null } - const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) const cartonFixtures = [] - let lastResponse = null for (const [index, cartonMark] of cartonCandidates.entries()) { const response = await context.requestOverseas('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) - lastResponse = response - const candidateExcluded = notFound(response) - context.addStep(MODULE, `筛选OVERSEAS实装箱唛候选${index + 1}`, response, - success(response) || candidateExcluded, - `${candidateExcluded ? 'candidateSelected=false' : 'candidateSelected=true'}; ${trace(response)}`, OPTIONS) - if (candidateExcluded) continue - if (!success(response)) { - blockerGap(context, `OVERSEAS箱唛候选${cartonMark}预检`, - '候选箱唛既非本柜冻结事实,也不是未被最终实装选中的A0404;禁止继续收货', - 'fixture-mismatch', response) - return false - } const resolved = response.body?.data - const inbound = inbounds.find((item) => item.inboundCode === resolved?.inboundCode) - const pallet = inbound ? pallets.get(inbound.inboundCode) : null - if (!inbound || !pallet) { - blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, - `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved?.inboundCode || 'missing'}`, + const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode) + const unique = success(response) && matches.length === 1 + && ['FBA', 'OVERSEAS'].includes(resolved?.destinationType) + && matches[0]?.destinationType === resolved?.destinationType + context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response, + unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS) + if (!unique) { + blockerGap(context, `箱码${cartonMark}唯一归属`, + `FBA和OVERSEAS箱码resolve都必须HTTP 200/code 200并返回当前柜唯一入仓码;inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}`, 'fixture-mismatch', response) - return false + return null } - cartonFixtures.push({ cartonMark, pallet }) + cartonFixtures.push({ cartonMark, inbound: matches[0], resolved }) } - const fixturesConserved = cartonFixtures.length === expectedPending - context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', lastResponse, fixturesConserved, - `resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}; ${trace(lastResponse)}`, OPTIONS) - if (!fixturesConserved) { - blockerGap(context, 'OVERSEAS逐箱冻结事实', - `候选池未完整覆盖本柜真实冻结箱唛;resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}`, - 'fixture-mismatch', lastResponse) + return cartonFixtures +} + +async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) { + const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode + && item.resolved.destinationType === 'FBA') + if (!resolvedCartons.length) { + blockerGap(context, `FBA入仓码${inbound.inboundCode}箱码解析`, '没有冻结箱码以200/code 200唯一解析到当前FBA入仓码') return false } - for (const [index, fixture] of cartonFixtures.entries()) { + const packageCount = Number(inbound.pendingPackages || 0) + if (packageCount <= 0) return true + const body = { inboundCode: inbound.inboundCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } + const first = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}按箱数实收`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA入仓码${inbound.inboundCode}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict) + await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}超收被拒绝`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) + return true +} + +async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures) { + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + const overseasFixtures = cartonFixtures.filter((item) => item.resolved.destinationType === 'OVERSEAS' + && inbounds.some((inbound) => inbound.inboundCode === item.inbound.inboundCode)) + .map((item) => ({ cartonMark: item.cartonMark, pallet: pallets.get(item.inbound.inboundCode) })) + const fixturesConserved = overseasFixtures.length === expectedPending && overseasFixtures.every((item) => item.pallet) + context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', null, fixturesConserved, + `resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, OPTIONS) + if (!fixturesConserved) { + blockerGap(context, 'OVERSEAS逐箱冻结事实', + `统一解析结果未完整覆盖本柜真实冻结箱码;resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, + 'fixture-mismatch') + return false + } + for (const [index, fixture] of overseasFixtures.entries()) { const { cartonMark, pallet } = fixture const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, `overseas-carton-${index}`) } @@ -482,6 +453,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe { ...signBody, photos: [...photos].reverse() }, conflict) const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(current.response)) return + const producerCartonMarks = preferredProducer?.cartonMarks?.length + ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark] + const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks) + if (!cartonFixtures) return const pallets = new Map() for (const [index, inbound] of current.data.inbounds.entries()) { const pallet = await createPallet(context, inbound, index) @@ -501,25 +476,30 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { const firstFba = fbaInbounds[0] + const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA' + && item.inbound.inboundCode === firstFba.inboundCode) + if (!fbaCarton) { + blockerGap(context, `FBA入仓码${firstFba.inboundCode}箱码反证`, '没有冻结FBA箱码可用于验证carton收货精确A0409') + return + } await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { - cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', + cartonMark: fbaCarton?.cartonMark, palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), - }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) - for (const inbound of fbaInbounds) { - await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), preferredProducer) + }, (response) => a0409Message(response, /^FBA destination must use bulk count$/)) + for (const [index, inbound] of fbaInbounds.entries()) { + await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index) } } else if (preferredProducer?.destinationType !== 'OVERSEAS') { - blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { const firstOverseas = overseasInbounds[0] const pallet = pallets.get(firstOverseas.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { - inboundCode: firstOverseas.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + inboundCode: firstOverseas.inboundCode, palletCode: pallet.palletCode, packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), - }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) - await receiveOverseas(context, token, overseasInbounds, pallets, - preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]) + }, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/)) + await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures) } else if (preferredProducer?.destinationType !== 'FBA') { blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') } @@ -541,7 +521,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, - pallets.get(inbound.inboundCode)) + pallets.get(inbound.inboundCode), inbound.destinationType) if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { context.context.phase4OverseasPdaOperations.completedFixtures.push({ containerNo, order, ...warehouseFacts, @@ -596,11 +576,7 @@ export async function run(context) { const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers - if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) { - blockerGap(context, '跨区消费生产矩阵', - '三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈') - return - } + if (allProducers.length) await awaitProducerConsumption(context, allProducers) if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { blockerGap(context, '实际处理柜PICKUP_READY门禁', 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') diff --git a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql index ee0cb5905..446ce56eb 100644 --- a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql +++ b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql @@ -102,7 +102,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); -CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code'); CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); @@ -118,6 +119,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); +UPDATE wh_partner SET carrier_code = 'OTHER' + WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL; -- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); diff --git a/warehouse/src/main/resources/db/warehouse-partner.sql b/warehouse/src/main/resources/db/warehouse-partner.sql index 10955f144..e4746c15d 100644 --- a/warehouse/src/main/resources/db/warehouse-partner.sql +++ b/warehouse/src/main/resources/db/warehouse-partner.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS wh_partner ( data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称', partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', @@ -16,7 +17,8 @@ CREATE TABLE IF NOT EXISTS wh_partner ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), KEY idx_wh_partner_tenant_status(tenant_id,status,deleted), - CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) + CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')), + CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')) ) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 443be6318..6c7c213f2 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -25,11 +25,12 @@ class WarehouseSchemaContractTest { void shouldHandleCase02() throws IOException { String sql = read("warehouse-partner.sql"); assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name", - "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", + "carrier_code", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", "postal_code", "status", "remark"); assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_partner_type") .contains("idx_wh_partner_type_scope") .contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") + .contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))") .contains("chk_wh_partner_type_value CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") .doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH"); assertBaseColumns(sql); @@ -100,6 +101,8 @@ class WarehouseSchemaContractTest { .contains("CALL add_column_if_missing('wh_warehouse_address'") .contains("CALL add_column_if_missing('wh_service_capability'") .contains("CALL add_column_if_missing('wh_partner'") + .contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL") + .contains("UPDATE wh_partner SET carrier_code = 'OTHER'") .contains("CALL add_column_if_missing('wh_express_company'") .contains("DROP PROCEDURE IF EXISTS add_column_if_missing") .doesNotContain("ADD COLUMN IF NOT EXISTS") @@ -150,7 +153,13 @@ class WarehouseSchemaContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); assertThat(tableDefinition(unified, "wh_partner")) .contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") + .contains("carrier_code VARCHAR(16) NULL") + .contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))") .doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH"); + assertThat(unified) + .contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL") + .contains("UPDATE wh_partner SET carrier_code = 'OTHER'") + .contains("queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code'"); } private static String read(String file) throws IOException {