From c216f393a7fb746c39f20154fc5bab3c624f6156 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 19:38:56 +0800 Subject: [PATCH] fix(fleet): preserve unknown commercial facts for pooled freight --- deploy/sql/README.md | 2 ++ ...12__preserve_unknown_pool_order_fields.sql | 10 +++++++++ deploy/sql/海外区域统一增量-MySQL8.sql | 6 +++--- .../fleet/business/FleetPoolOrderBridge.java | 5 ++++- .../dto/LocalLooseboardResponses.java | 1 + .../trucking/dto/UsTruckOrderResponses.java | 1 + .../src/main/resources/db/lastleg.sql | 6 +++--- .../openapi/overseas-business-v1.yaml | 8 +++---- .../business/FleetPoolOrderBridgeTest.java | 21 +++++++++++++++++++ .../LocalLooseboardSchemaContractTest.java | 16 ++++++++++++++ .../UsTruckOrderSchemaContractTest.java | 15 +++++++++++++ ...eet-capacity-pool-schema-contract.test.mjs | 12 +++++++++++ scripts/fleet-capacity-pool-test-env.mjs | 14 ++++++++++++- scripts/fleet-capacity-pool-test-env.test.mjs | 19 ++++++++++++++++- 14 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql diff --git a/deploy/sql/README.md b/deploy/sql/README.md index be1ea2931..c87c8917e 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。 + 内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。 ## 唯一入口与区域边界 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql new file mode 100644 index 000000000..99eda2804 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql @@ -0,0 +1,10 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 拼车商业单未提供的柜型和打盘需求保持未知 +-- Preconditions: 既有两类商业订单表存在,普通订单仍由服务端执行原字段校验 +-- Postconditions: 三列允许NULL且默认NULL;不回填或覆盖既有订单事实 +ALTER TABLE ll_local_looseboard_order + MODIFY COLUMN pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', + MODIFY COLUMN container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜'; +ALTER TABLE ll_us_truck_order + MODIFY COLUMN container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 68869770b..2a4603f92 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1341,13 +1341,13 @@ CREATE TABLE ll_local_looseboard_order ( lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载', - pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '是否需要打盘:YES或NO', + pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号', - container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', + container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', package_count int NOT NULL DEFAULT '0' COMMENT '货物件数', total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)', total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', @@ -2522,7 +2522,7 @@ CREATE TABLE ll_us_truck_order ( origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址', - container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', + container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数', package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index a429c0407..7256d4e75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -55,7 +55,9 @@ public class FleetPoolOrderBridge { String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR"; if("LOCAL_LOOSEBOARD".equals(in.businessType())) { var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); - order.setTransportMode("SHARED_TRUCK");order.setPalletRequired("YES");order.setTransportType(transport); + order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport); + // A pallet count/space is not a palletizing service request or a container specification. + order.setPalletRequired(null);order.setContainerType(null); order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount()); order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency()); order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate()); @@ -65,6 +67,7 @@ public class FleetPoolOrderBridge { } else { var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); order.setTransportType(transport);order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); + order.setContainerType(null); // Pool inquiry does not collect a container specification. order.setPalletCount(in.expectedPalletCount());order.setPackageCount(in.packageCount());order.setReceivedPalletCount(0);order.setAllocatedPalletCount(0); order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency()); order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java index 3d2e33ccc..c822371f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -8,6 +8,7 @@ import java.util.List; public final class LocalLooseboardResponses { private LocalLooseboardResponses() { } + /** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */ public record Summary( Long id, String customerId, String orderNo, String transportMode, String palletRequired, String transportType, String origin, String destination, String containerType, int packageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java index c219b71b1..d3f9ccad2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -8,6 +8,7 @@ import java.util.List; public final class UsTruckOrderResponses { private UsTruckOrderResponses() {} + /** Pool orders retain null containerType; forecast physical pallets do not imply a container type. */ public record Summary( Long id, String customerId, String orderNo, String transportType, String origin, String destination, String containerType, int palletCount, int packageCount, diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e4ce34539..8c187eeb6 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2661,7 +2661,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( origin_address VARCHAR(500) NULL COMMENT '起运地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', - container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', + container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', @@ -2736,10 +2736,10 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', - pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', - container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 2858baecf..6cfc4e2cd 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3362,7 +3362,7 @@ components: transportType: { type: string, nullable: true } origin: { type: string } destination: { type: string } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } + containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ } palletCount: { type: integer } packageCount: { type: integer } receivedPalletCount: { type: integer } @@ -3411,17 +3411,17 @@ components: LooseboardSummary: type: object description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。 - required: [id, customerId, orderNo, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] + required: [id, customerId, orderNo, transportMode, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] properties: id: { type: string, pattern: '^[0-9]+$' } customerId: { type: string } orderNo: { type: string } transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } - palletRequired: { type: string, enum: [YES, NO] } + palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES } transportType: { type: string } origin: { type: string } destination: { type: string } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } + containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ } packageCount: { type: integer } totalWeightKg: { type: number } totalVolumeCbm: { type: number } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index c9fe7a4aa..125b583c3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -74,6 +74,27 @@ class FleetPoolOrderBridgeTest { assertThat(truckOrder.getBudgetAmount()).isNull();assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH"); verifyNoInteractions(loose); } + @Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){ + var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op"); + assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull(); + assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK"); + assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE"); + assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel()); + assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount()); + assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm()); + assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull(); + incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); + assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull(); + } + @Test void truckInquiryPreservesUnknownContainerWhileZeroActualCountsRemainRealUntilReceipt(){ + var source=input("US_TRUCK");service.confirm(source,"op"); + assertThat(truckOrder.getContainerType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount()); + assertThat(truckOrder.getReceivedPalletCount()).isZero();assertThat(truckOrder.getAllocatedPalletCount()).isZero(); + assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE"); + assertThat(truckOrder.getOriginAddress()).isNull();assertThat(truckOrder.getDestinationAddress()).isNull();assertThat(truckOrder.getBudgetAmount()).isNull(); + incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); + assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getContainerType()).isNull(); + } @Test void incomingMustMatchCustomerWarehouseAndDestinationAndNeverBindSecondTailOrder(){ service.confirm(input("LOCAL_LOOSEBOARD"),"op"); assertThatThrownBy(()->service.bindIncoming("FB-7","another","WT-7",77,"92","FBA:ONT8","op")).hasMessageContaining("客户"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java index 786be45df..a6785bff9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -43,4 +43,20 @@ class LocalLooseboardSchemaContractTest { int end = sql.indexOf(";", start); return start < 0 || end < 0 ? "" : sql.substring(start, end); } + + @Test + void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception { + String baseline; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + for (String sql : java.util.List.of(baseline, unified)) { + String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase(); + assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), + "Unknown container must remain null when MyBatis omits its insert column"); + assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), + "Forecast pallets must not invent YES or NO palletizing service"); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java index b6f698d62..1258d91fd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -48,4 +48,19 @@ class UsTruckOrderSchemaContractTest { int end = sql.indexOf(';', start); return start < 0 || end < 0 ? "" : sql.substring(start, end); } + + @Test + void poolUnknownContainerSurvivesOmittedNullInsertColumnInBothBaselines() throws Exception { + String baseline; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + for (String sql : java.util.List.of(baseline, unified)) { + String normalized = sql.toLowerCase().replace("create table if not exists", "create table"); + String block = tableBlock(normalized, "ll_us_truck_order"); + assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), + "Unknown container must remain null when MyBatis omits its insert column"); + } + } } diff --git a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs index e6cc9080e..d05c38d18 100644 --- a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs @@ -55,6 +55,18 @@ test('customer identity joins use the same explicit collation as existing fulfil assert.doesNotMatch(sql.replace(/^--.*$/gm, ''), /\b(?:DROP|TRUNCATE|DELETE|UPDATE|REPLACE)\b/i) }) +test('commercial schemas do not invent container or palletizing facts for pool orders', () => { + for (const name of ['ll_local_looseboard_order', 'll_us_truck_order']) { + const definition = table(overseas, 'baiyue_lastleg', name) + assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i) + assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i) + } + assert.match(table(overseas, 'baiyue_lastleg', 'll_local_looseboard_order'), /pallet_required\s+varchar\(8\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i) + const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql') + assert.equal((sql.match(/MODIFY COLUMN/g) || []).length, 3) + assert.doesNotMatch(sql.replace(/^--.*$/gm, ''), /\b(?:DROP|TRUNCATE|DELETE|UPDATE|REPLACE)\b/i) +}) + test('pool permissions are independent of trip details and default only to the platform administrator', () => { const permissions = seedRows(cn, 'iam_permission').filter(row => String(row.permission_code).startsWith('platform:fleet:capacity-offer:')) assert.equal(permissions.length, 5) diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index bc6154d48..367c3ff8a 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -34,6 +34,7 @@ export const POOL_COVERAGE = Object.freeze([ 'receipt-overcapacity-rejected-with-source-unchanged', 'open-offer-bind-replan-requires-explicit-resume', 'bound-cargo-reservation-removed-without-double-count', 'other-sales-write-and-sensitive-trip-denied', 'other-customer-commercial-read-denied-with-source-unchanged', + 'pool-unspecified-container-and-palletizing-remain-unknown', ]) export const POOL_REMAINING = Object.freeze([ 'out-of-band-assigned-vehicle-drift-not-constructible-through-supported-api', @@ -62,6 +63,14 @@ export function assertSingleCommercial(before, after) { && before.customerId === after.customerId, 'COMMERCIAL_REPLAY_IDENTITY_CHANGED') } +export function assertPoolUnknownCommercialFacts(businessType, summary) { + requireThat(['LOCAL_LOOSEBOARD', 'US_TRUCK'].includes(businessType) && summary && typeof summary === 'object', + 'POOL_COMMERCIAL_SUMMARY_REQUIRED') + requireThat(summary.containerType == null, 'POOL_CONTAINER_TYPE_MUST_REMAIN_UNKNOWN') + if (businessType === 'LOCAL_LOOSEBOARD') requireThat(summary.palletRequired == null, 'POOL_PALLETIZING_SERVICE_MUST_REMAIN_UNKNOWN') + return true +} + export function poolInquiryWrite(inquiry, idempotencyKey) { return { tripRecordVersion: inquiry.tripRecordVersion, offerRecordVersion: inquiry.offerRecordVersion, recordVersion: inquiry.recordVersion, customerId: inquiry.customerId, businessType: inquiry.businessType, @@ -397,6 +406,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory, const poolMembers = [] for (const [index, order] of proof.orders.entries()) { const source = await request('GET', businessPath(order)) + check('New pool commercial order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, source.summary)) check('Pool commercial order has source arrangement and waits for real inbound', source.summary.status === 'WAITING_INBOUND' && source.summary.fleetArrangement?.bookingNo === order.bookingNo && !source.summary.fleetArrangement.incomingNo) const oldStatusPath = businessPath(order).replace('?', '/status?') @@ -459,6 +469,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory, proof.covered.push('shared-real-departure-and-signature') for (const [index, order] of proof.orders.entries()) { const commercial = await request('GET', businessPath(order)) + check('Completed commercial order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, commercial.summary)) check('Each business type completes from actual signature', commercial.summary.status === 'COMPLETED' && commercial.summary.fleetArrangement?.fulfillmentStage === 'COMPLETED' && Number(commercial.summary.fleetArrangement.signedPackages) === 1) const member = poolMembers[index], actual = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${order.customerId}`) @@ -467,9 +478,10 @@ async function poolScenario(context, fixture, { execute = false, readInventory, const merchant = merchantSessions.find(session => session.customerId === order.customerId) const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}` const own = await request('GET', merchantPath, undefined, merchant.accessToken) + check('Client completed order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, own.summary)) check('Client sees only own complete commercial arrangement', own.summary.orderNo === order.businessOrderNo && own.summary.fleetArrangement?.businessOrderNo === order.businessOrderNo) } - proof.covered.push('business-completion-and-fee-uniqueness') + proof.covered.push('business-completion-and-fee-uniqueness', 'pool-unspecified-container-and-palletizing-remain-unknown') proof.poolMembers = poolMembers; proof.status = 'VERIFIED' return proof } catch (error) { proof.status = 'FAILED'; throw error } diff --git a/scripts/fleet-capacity-pool-test-env.test.mjs b/scripts/fleet-capacity-pool-test-env.test.mjs index 8721311f4..4d6cd974b 100644 --- a/scripts/fleet-capacity-pool-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-test-env.test.mjs @@ -2,7 +2,8 @@ import test from 'node:test' import assert from 'node:assert/strict' import { parsePoolArguments, poolPlan, runPoolAcceptance, assertPoolRemaining, assertSingleCommercial, shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING, - poolInquiryWrite, poolReadIsDenied, assertPoolBoundCapacity, verifyPoolConfirmedCancellation, verifyPoolBindingRevalidation } from './fleet-capacity-pool-test-env.mjs' + poolInquiryWrite, poolReadIsDenied, assertPoolBoundCapacity, verifyPoolConfirmedCancellation, verifyPoolBindingRevalidation, + assertPoolUnknownCommercialFacts } from './fleet-capacity-pool-test-env.mjs' test('default and explicit plans make no network requests or invoke the mutating runner', async () => { const original = globalThis.fetch @@ -167,6 +168,22 @@ test('foreign read rejection requires actual access/not-found code and no leaked { http: 200, body: { code: 'A0409' } }, { http: 403, body: { data: { orderNo: 'PRIVATE' } } }]) assert.equal(poolReadIsDenied(response), false) }) +test('pool commercial facts accept nullish Jackson fields but reject invented container or palletizing answers', () => { + for (const type of ['LOCAL_LOOSEBOARD', 'US_TRUCK']) { + for (const summary of [{}, { containerType: null, palletRequired: null }, { containerType: undefined, palletRequired: undefined }]) { + assert.doesNotThrow(() => assertPoolUnknownCommercialFacts(type, summary)) + } + for (const containerType of ['40HQ', '40GP', '45HQ', '', 'UNKNOWN', false, 0]) { + assert.throws(() => assertPoolUnknownCommercialFacts(type, { containerType }), /CONTAINER_TYPE_MUST_REMAIN_UNKNOWN/) + } + assert.throws(() => assertPoolUnknownCommercialFacts(type, null), /SUMMARY_REQUIRED/) + } + for (const palletRequired of ['YES', 'NO', '', 'UNKNOWN', false, 0]) { + assert.throws(() => assertPoolUnknownCommercialFacts('LOCAL_LOOSEBOARD', { palletRequired }), /PALLETIZING_SERVICE_MUST_REMAIN_UNKNOWN/) + } + assert.throws(() => assertPoolUnknownCommercialFacts('OTHER', {}), /SUMMARY_REQUIRED/) +}) + test('owner rejection sends a valid current edit rather than malformed input', () => { const source = { tripRecordVersion: 5, offerRecordVersion: 7, recordVersion: 9, customerId: '42', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: '2030-01-01T00:00:00Z', expectedPalletCount: 1,