diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5628b33dd..f9f53e5b2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8716,6 +8716,109 @@ ALTER TABLE fl_ocean_booking MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'; +-- Reconcile databases created before the direct-CONFIRMED contract. This is +-- intentionally fail-closed: no legacy row is promoted and no transport term +-- is guessed. Referenced legacy aggregates must be archived/deleted by the +-- owning module first, then this block can be rerun safely. +DROP TRIGGER IF EXISTS trg_ocean_booking_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_party_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_history_before_delete; +DELIMITER $$ +DROP PROCEDURE IF EXISTS reconcile_ocean_booking_legacy$$ +CREATE PROCEDURE reconcile_ocean_booking_legacy() +BEGIN + DECLARE v_blank_terms INT DEFAULT 0; + DECLARE v_legacy INT DEFAULT 0; + DECLARE v_external INT DEFAULT 0; + DECLARE v_sql TEXT; + + SELECT COUNT(*) INTO v_blank_terms + FROM fl_ocean_booking + WHERE NULLIF(TRIM(service_term),'') IS NULL + OR NULLIF(TRIM(freight_term),'') IS NULL; + IF v_blank_terms > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '订舱结构收敛暂停:存在 service_term/freight_term 为空的真实行,请按原始业务资料补齐后重跑,禁止猜值'; + END IF; + + SELECT COUNT(*) INTO v_legacy + FROM fl_ocean_booking + WHERE status IN ('DRAFT','SUBMITTED','FAILED'); + IF v_legacy > 0 THEN + -- Tables from later phase blocks may not exist on a module-only run. Use + -- dynamic SQL so a clean initialization remains repeatable. + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='fl_port_schedule') THEN + SET @ocean_legacy_external := 0; + SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_port_schedule p JOIN fl_ocean_booking b ON b.id=p.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')'; + PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET v_external = v_external + COALESCE(@ocean_legacy_external,0); + END IF; + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query') THEN + SET @ocean_legacy_external := 0; + SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_carrier_schedule_query q JOIN fl_ocean_booking b ON b.id=q.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')'; + PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET v_external = v_external + COALESCE(@ocean_legacy_external,0); + END IF; + IF v_external > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '订舱结构收敛暂停:旧订舱仍被港务船期/船司查询引用,请先按子表历史->主表归档顺序清理引用后重跑;不会伪造 CONFIRMED'; + END IF; + + DELETE FROM fl_ocean_booking_party + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_container_allocation + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_container_requirement + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_operation + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_status_history + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking + WHERE status IN ('DRAFT','SUBMITTED','FAILED'); + END IF; +END$$ +CALL reconcile_ocean_booking_legacy()$$ +DROP PROCEDURE reconcile_ocean_booking_legacy$$ +DELIMITER ; + +-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS. +SET @ocean_drop_status_check = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_status', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND constraint_name='chk_ocean_booking_status' +); +PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; +EXECUTE ocean_drop_status_stmt; +DEALLOCATE PREPARE ocean_drop_status_stmt; +ALTER TABLE fl_ocean_booking + MODIFY COLUMN service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照', + MODIFY COLUMN freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照', + ADD CONSTRAINT chk_ocean_booking_status CHECK + (status IN ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')); + +SET @ocean_drop_failure_reason = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP COLUMN failure_reason', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND column_name='failure_reason' +); +PREPARE ocean_drop_failure_stmt FROM @ocean_drop_failure_reason; +EXECUTE ocean_drop_failure_stmt; +DEALLOCATE PREPARE ocean_drop_failure_stmt; + CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java index ea7358e8f..8c094d55e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -26,7 +26,7 @@ public class InboundPalletAllocationRepository { Map reservationNos = mapper.selectList(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)) - .stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getPalletId, + .stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getReservationId, InboundPalletAllocationEntity::getReservationNo, (left, right) -> left)); mapper.delete(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) @@ -58,7 +58,7 @@ public class InboundPalletAllocationRepository { value.setReservationId(source.reservationId()); // 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。 value.setReservationNo(source.reservationNo() != null ? source.reservationNo() - : reservationNos.get(source.palletId())); + : reservationNos.get(source.reservationId())); value.setAllocationStatus(normalize(source.state())); value.setCreatedAt(now); value.setUpdatedAt(now); diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 8d5221b64..10fba107c 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -60,6 +60,109 @@ ALTER TABLE fl_ocean_booking MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'; +-- Reconcile databases created before the direct-CONFIRMED contract. This is +-- intentionally fail-closed: no legacy row is promoted and no transport term +-- is guessed. Referenced legacy aggregates must be archived/deleted by the +-- owning module first, then this block can be rerun safely. +DROP TRIGGER IF EXISTS trg_ocean_booking_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_party_before_update; +DROP TRIGGER IF EXISTS trg_ocean_party_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update; +DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete; +DROP TRIGGER IF EXISTS trg_ocean_history_before_delete; +DELIMITER $$ +DROP PROCEDURE IF EXISTS reconcile_ocean_booking_legacy$$ +CREATE PROCEDURE reconcile_ocean_booking_legacy() +BEGIN + DECLARE v_blank_terms INT DEFAULT 0; + DECLARE v_legacy INT DEFAULT 0; + DECLARE v_external INT DEFAULT 0; + DECLARE v_sql TEXT; + + SELECT COUNT(*) INTO v_blank_terms + FROM fl_ocean_booking + WHERE NULLIF(TRIM(service_term),'') IS NULL + OR NULLIF(TRIM(freight_term),'') IS NULL; + IF v_blank_terms > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '订舱结构收敛暂停:存在 service_term/freight_term 为空的真实行,请按原始业务资料补齐后重跑,禁止猜值'; + END IF; + + SELECT COUNT(*) INTO v_legacy + FROM fl_ocean_booking + WHERE status IN ('DRAFT','SUBMITTED','FAILED'); + IF v_legacy > 0 THEN + -- Tables from later phase blocks may not exist on a module-only run. Use + -- dynamic SQL so a clean initialization remains repeatable. + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='fl_port_schedule') THEN + SET @ocean_legacy_external := 0; + SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_port_schedule p JOIN fl_ocean_booking b ON b.id=p.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')'; + PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET v_external = v_external + COALESCE(@ocean_legacy_external,0); + END IF; + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query') THEN + SET @ocean_legacy_external := 0; + SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_carrier_schedule_query q JOIN fl_ocean_booking b ON b.id=q.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')'; + PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET v_external = v_external + COALESCE(@ocean_legacy_external,0); + END IF; + IF v_external > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '订舱结构收敛暂停:旧订舱仍被港务船期/船司查询引用,请先按子表历史->主表归档顺序清理引用后重跑;不会伪造 CONFIRMED'; + END IF; + + DELETE FROM fl_ocean_booking_party + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_container_allocation + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_container_requirement + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_operation + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking_status_history + WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED')); + DELETE FROM fl_ocean_booking + WHERE status IN ('DRAFT','SUBMITTED','FAILED'); + END IF; +END$$ +CALL reconcile_ocean_booking_legacy()$$ +DROP PROCEDURE reconcile_ocean_booking_legacy$$ +DELIMITER ; + +-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS. +SET @ocean_drop_status_check = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_status', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND constraint_name='chk_ocean_booking_status' +); +PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; +EXECUTE ocean_drop_status_stmt; +DEALLOCATE PREPARE ocean_drop_status_stmt; +ALTER TABLE fl_ocean_booking + MODIFY COLUMN service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照', + MODIFY COLUMN freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照', + ADD CONSTRAINT chk_ocean_booking_status CHECK + (status IN ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')); + +SET @ocean_drop_failure_reason = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP COLUMN failure_reason', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' + AND column_name='failure_reason' +); +PREPARE ocean_drop_failure_stmt FROM @ocean_drop_failure_reason; +EXECUTE ocean_drop_failure_stmt; +DEALLOCATE PREPARE ocean_drop_failure_stmt; + CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index 99462012f..40dc4e06c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -42,6 +42,19 @@ class OceanBookingSqlContractTest { .contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)"); } + @Test + void reconcilesExistingDatabasesWithoutGuessingLegacyBusinessData() throws Exception { + assertThat(sql()) + .contains("reconcile_ocean_booking_legacy") + .contains("service_term/freight_term 为空的真实行") + .contains("status IN ('DRAFT','SUBMITTED','FAILED')") + .contains("不会伪造 CONFIRMED") + .contains("DROP CHECK chk_ocean_booking_status") + .contains("ADD CONSTRAINT chk_ocean_booking_status CHECK") + .contains("DROP COLUMN failure_reason") + .doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'"); + } + @Test void declaresDedicatedPlatformPermissions() throws Exception { assertThat(sql()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java index 9b27cc89e..9d006173a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepositoryTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -9,10 +10,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; +import java.math.BigDecimal; +import org.mockito.ArgumentCaptor; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class InboundPalletAllocationRepositoryTest { @@ -42,6 +47,30 @@ class InboundPalletAllocationRepositoryTest { .doesNotThrowAnyException(); } + @Test + void restoresReservationNumbersByReservationIdWhenPalletIsReused() { + InboundPalletAllocationEntity first = allocation(1L, 91L, "RESERVED", 1, 0); + first.setPalletId(501L); + first.setReservationNo("LR-91"); + InboundPalletAllocationEntity second = allocation(2L, 92L, "RESERVED", 1, 0); + second.setPalletId(501L); + second.setReservationNo("LR-92"); + when(mapper.selectList(any())).thenReturn(List.of(first, second)); + + var result = new WarehousePalletLocationClient.BatchResult(2, List.of( + new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", 11L, "A1", + new BigDecimal("1.2"), "OCCUPIED"), + new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", 12L, "A2", + new BigDecimal("1.2"), "OCCUPIED"))); + + repository.replace(10001L, 31L, 88L, 0L, new BigDecimal("1.2"), 2, result); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class); + verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture()); + assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getReservationNo) + .containsExactly("LR-91", "LR-92"); + } + private static InboundPalletAllocationEntity allocation(long id, long reservationId, String status, int allocated, int picked) { InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index e42ec709c..bccad2620 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -54,6 +54,18 @@ class Phase3FinalUnifiedSqlContractTest { .contains("trg_loading_pick_operation_before_delete"); } + @Test + void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception { + String sql = normalise(Files.readString(UNIFIED)); + assertThat(sql) + .contains("reconcile_ocean_booking_legacy") + .contains("DROP CHECK chk_ocean_booking_status") + .contains("ADD CONSTRAINT chk_ocean_booking_status CHECK") + .contains("DROP COLUMN failure_reason") + .contains("不会伪造 CONFIRMED") + .doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'"); + } + @Test void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception { String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index b67220224..2c04a9594 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -152,6 +152,14 @@ export async function run(context) { const bookingId = id(bookingCreated?.oceanBooking?.id) if (!bookingId) return const bookingConfirmed = bookingCreated + const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]])) + const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') + context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse, + frozenTransportValid + && transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])), + `bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) + if (!frozenTransportValid) return const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') context.assert(MODULE, '一次登记直接返回 CONFIRMED 且不自动生成三期内部柜', bookingCreateResponse, bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id) @@ -279,19 +287,16 @@ export async function run(context) { `HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}, task=${id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) if (!confirmedBookingTaskAbsent) return - const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() - const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString() const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, { expectedVersion: Number(bookingConfirmed?.oceanBooking?.version), - billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), vesselName: `P3VESSEL-${suffix}`.slice(0, 128), - voyageNo: `P3V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt, - estimatedArrivalAt: arrivalAt, reason: context.marker, + ...frozenTransport, reason: context.marker, }, { 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) }) const bookingReleased = context.apiSuccess(MODULE, '三期订舱携带已冻结运输快照并放舱', releaseResponse, '', OPTIONS) context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份', releaseResponse, bookingReleased?.oceanBooking?.status === 'RELEASED' - && bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId), - `status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) + && bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId) + && transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])), + `status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, transportFrozen=${transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS) if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 5c9ac2e7d..a4901bbae 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -185,6 +185,9 @@ test('container loading arranges a simulation before linking a confirmed real bo assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport")) assert.match(value, /在放舱前绑定本轮真实箱号/) assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/) + assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) + assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/) + assert.doesNotMatch(value, /new Date\(Date\.now\(\)/) assert.match(value, /无真实柜号拒绝推送预装计划/) assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/) assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index 84ce49aed..d280dac40 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -102,7 +102,7 @@ public class InternalPalletLocationController { } @PostMapping("/rebind-temporary-location") - @Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位") + @Operation(summary = "A0临时库位换绑", description = "将一条具体A0库位占用记录换绑到同仓空闲普通库位") public ApiResponse rebindTemporaryLocation( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.RebindTemporaryLocationRequest request) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java index e00ec9bac..6d6a90412 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java @@ -137,7 +137,7 @@ public class PlatformLocationController { @PostMapping("/temporary-occupancies/{occupancyId}/rebind") @PreAuthorize("hasAuthority('platform:location:edit')") - @Operation(summary = "A0临时库位换绑", description = "将指定A0托盘占用换绑到同仓空闲普通库位") + @Operation(summary = "A0临时库位换绑", description = "将指定A0库位占用记录换绑到同仓空闲普通库位") public ApiResponse rebindTemporaryLocation( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long occupancyId, @Valid @RequestBody RebindRequest request) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java index 2a6f454b7..d187bfc4c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java @@ -67,7 +67,7 @@ public final class LocationDtos { public record Detail(LocationEntity location, String warehouseCode, String warehouseName) { } - /** 单个托盘的幂等预占目标。 */ + /** 单条库位预占目标;托盘字段仅引用可复用主数据快照。 */ public record ReservationTarget( @NotNull @Min(1) Long palletId, @NotBlank @Size(max = 50) String palletCode, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java index 050da95d7..979918d4f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java @@ -131,7 +131,7 @@ public final class PalletAllocationDtos { ) { } - /** 将共享临时库位 A0 中的一条具体托盘占用换绑到同仓空闲普通库位。 */ + /** 将共享临时库位 A0 中的一条具体库位占用换绑到同仓空闲普通库位。 */ public record RebindTemporaryLocationRequest( @Min(0) long tenantId, @Min(1) long occupancyId, @@ -155,7 +155,7 @@ public final class PalletAllocationDtos { ) { } - /** 仅转移占用归属和未拣出箱数,不释放库位或托盘。 */ + /** 仅转移库位占用归属和未拣出箱数;托盘仅作为主数据快照关联。 */ public record TransferRequest( @Min(0) long tenantId, @NotBlank @Size(max = 40) String sourceService, @@ -168,7 +168,7 @@ public final class PalletAllocationDtos { ) { } - /** 子入库单保存托盘库位快照所需的完整仓储分配结果。 */ + /** 子入库单保存托盘主数据和库位快照所需的完整仓储分配结果。 */ public record TransferResult( long reservationId, String reservationNo, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationReleaseOperationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationReleaseOperationMapper.java index e9dda39f4..1e171a515 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationReleaseOperationMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationReleaseOperationMapper.java @@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; -/** 已占用托盘库位选择性释放的幂等操作 Mapper。 */ +/** 已占用库位选择性释放的幂等操作 Mapper。 */ @Mapper public interface PalletLocationReleaseOperationMapper extends BaseMapper { @Insert(""" diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationTransferOperationMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationTransferOperationMapper.java index a1e9148e3..637893ef6 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationTransferOperationMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletLocationTransferOperationMapper.java @@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; -/** 托盘库位转移幂等操作 Mapper。 */ +/** 库位归属转移幂等操作 Mapper。 */ @Mapper public interface PalletLocationTransferOperationMapper extends BaseMapper { @Insert(""" diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationReleaseOperationRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationReleaseOperationRepository.java index a53b72652..7400149f4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationReleaseOperationRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationReleaseOperationRepository.java @@ -6,7 +6,7 @@ import com.baiyue.warehouse.mapper.PalletLocationReleaseOperationMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; -/** 已占用托盘库位选择性释放的持久化幂等边界。 */ +/** 已占用库位选择性释放的持久化幂等边界。 */ @Repository public class PalletLocationReleaseOperationRepository { @Resource diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationTransferOperationRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationTransferOperationRepository.java index 61f04f2fe..b88338e58 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationTransferOperationRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletLocationTransferOperationRepository.java @@ -6,7 +6,7 @@ import com.baiyue.warehouse.mapper.PalletLocationTransferOperationMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; -/** 托盘库位归属转移的幂等操作持久化边界。 */ +/** 库位归属转移的幂等操作持久化边界。 */ @Repository public class PalletLocationTransferOperationRepository { @Resource diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index c3d56bedc..db4164965 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -108,7 +108,7 @@ public class LocationResourceService { result.add(toReservationResult(reservation)); } } catch (DataIntegrityViolationException exception) { - throw new BizException(ResultCode.CONFLICT, "库位预占幂等键、托盘或库位已被占用"); + throw new BizException(ResultCode.CONFLICT, "库位预占幂等键重复或库位已存在活动预占"); } return List.copyOf(result); } @@ -151,7 +151,7 @@ public class LocationResourceService { result.add(toOccupancyResult(occupancy)); } } catch (DataIntegrityViolationException exception) { - throw new BizException(ResultCode.CONFLICT, "库位或托盘已存在活动占用"); + throw new BizException(ResultCode.CONFLICT, "库位已存在活动占用或预占记录已确认"); } return List.copyOf(result); } @@ -336,7 +336,7 @@ public class LocationResourceService { /** * 将未拣货的已占用库位从主入库单转给二级子入库单。 - * 转移只改变业务归属和剩余箱数,不释放库位或托盘。 + * 转移只改变业务归属和剩余箱数,不释放库位;托盘仅作为关联快照。 */ @Transactional public List transferToChild(@Valid LocationDtos.TransferCommand command) { @@ -394,7 +394,7 @@ public class LocationResourceService { return List.copyOf(results); } - /** PDA 结算按预约记录转移未拣货资源,物理托盘和库位继续保持占用。 */ + /** PDA 结算按预约记录转移未拣货资源,库位继续保持占用,托盘仅作为主数据快照关联。 */ @Transactional public List transferToChildByReservation( @Valid LocationDtos.ReservationTransferCommand command) { @@ -471,7 +471,7 @@ public class LocationResourceService { .toList(); } - /** 将 A0 的一条托盘占用换绑到同仓空闲普通库位。 */ + /** 将 A0 的一条库位占用记录换绑到同仓空闲普通库位,保留托盘主数据关联。 */ @Transactional public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation( @Valid LocationDtos.RebindTemporaryLocationCommand command) { @@ -648,7 +648,7 @@ public class LocationResourceService { || reservation.getWarehouseId() != command.warehouseId() || reservation.getPalletId() != target.palletId() || !reservation.getPalletCode().equals(target.palletCode())) { - throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘"); + throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘主数据"); } if ("RELEASED".equals(reservation.getReservationStatus()) || "EXPIRED".equals(reservation.getReservationStatus())) { throw new BizException(ResultCode.CONFLICT, "库位预占幂等请求已释放或过期"); @@ -684,11 +684,12 @@ public class LocationResourceService { } private static void validateUniqueTargets(List targets) { - Set pallets = new HashSet<>(); Set keys = new HashSet<>(); for (ReservationTarget target : targets) { - if (!pallets.add(target.palletId()) || !keys.add(target.idempotencyKey())) { - throw new BizException(ResultCode.BAD_REQUEST, "同批预占的托盘和幂等键不得重复"); + // Pallets are reusable master-data references. Only the idempotency key + // identifies a unique reservation target within one batch. + if (!keys.add(target.idempotencyKey())) { + throw new BizException(ResultCode.BAD_REQUEST, "同批预占的幂等键不得重复"); } } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index e8133e04c..4d940a3ce 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -185,7 +185,7 @@ public class PalletLocationAllocationService { */ @Transactional @BusinessAudit(event = "PALLET_LOCATION_SETTLEMENT", resourceType = "INBOUND", - resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算托盘库位资源") + resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算库位资源") public PalletAllocationDtos.SettlementResult settle(PalletAllocationDtos.SettlementRequest request) { validateSettlementScope(request); String key = request.idempotencyKey().trim(); diff --git a/warehouse/src/main/resources/db/phase3-pallet-location-release-idempotency.sql b/warehouse/src/main/resources/db/phase3-pallet-location-release-idempotency.sql index 82d6b7c89..d2a632c38 100644 --- a/warehouse/src/main/resources/db/phase3-pallet-location-release-idempotency.sql +++ b/warehouse/src/main/resources/db/phase3-pallet-location-release-idempotency.sql @@ -1,4 +1,4 @@ --- PDA 已拣满库位/托盘精确释放的持久化幂等契约;可在二期库位表建表后重复执行。 +-- PDA 已拣满库位精确释放的持久化幂等契约;可在二期库位表建表后重复执行。 SET NAMES utf8mb4; CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation ( @@ -16,4 +16,4 @@ CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation ( UNIQUE KEY uk_pallet_location_release_idempotency (tenant_id, source_service, business_id, idempotency_key), KEY idx_pallet_location_release_business (tenant_id, source_service, business_id, operation_status) -) ENGINE=InnoDB COMMENT='PDA已拣满托盘库位精确释放的持久化幂等操作记录'; +) ENGINE=InnoDB COMMENT='PDA已拣满库位精确释放的持久化幂等操作记录'; diff --git a/warehouse/src/main/resources/db/phase3-pallet-location-settlement-idempotency.sql b/warehouse/src/main/resources/db/phase3-pallet-location-settlement-idempotency.sql index 92514c28c..395b2a38e 100644 --- a/warehouse/src/main/resources/db/phase3-pallet-location-settlement-idempotency.sql +++ b/warehouse/src/main/resources/db/phase3-pallet-location-settlement-idempotency.sql @@ -1,4 +1,4 @@ --- PDA 拣货资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。 +-- PDA 拣货库位资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。 SET NAMES utf8mb4; CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation ( diff --git a/warehouse/src/main/resources/db/phase3-pallet-location-transfer-idempotency.sql b/warehouse/src/main/resources/db/phase3-pallet-location-transfer-idempotency.sql index 1dcf759f6..17d3b45e7 100644 --- a/warehouse/src/main/resources/db/phase3-pallet-location-transfer-idempotency.sql +++ b/warehouse/src/main/resources/db/phase3-pallet-location-transfer-idempotency.sql @@ -1,4 +1,4 @@ --- 托盘库位转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。 +-- 库位归属转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。 SET NAMES utf8mb4; CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation ( @@ -17,4 +17,4 @@ CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation ( UNIQUE KEY uk_pallet_location_transfer_idempotency (tenant_id, source_service, source_business_id, target_business_id, idempotency_key), KEY idx_pallet_location_transfer_source (tenant_id, source_service, source_business_id, operation_status) -) ENGINE=InnoDB COMMENT='托盘库位归属转移的持久化幂等操作记录'; +) ENGINE=InnoDB COMMENT='库位归属转移的持久化幂等操作记录'; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java index 1e7958579..0f14d6ce9 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; @@ -25,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -117,6 +119,37 @@ class LocationResourceServiceTest { .containsExactly("inbound-1-p1", "inbound-1-p2"); } + @Test + void allowsReusablePalletMasterInMultipleReservationTargets() { + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + when(repository.lockAvailable(10001L, 88L, 2)) + .thenReturn(List.of(location(9L, "A1"), location(10L, "A2"))); + when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); + when(repository.changeStatus(10001L, 88L, 10L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); + + var command = new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L, + LocalDateTime.now().plusMinutes(30), List.of( + new ReservationTarget(501L, "P1", "same-pallet-1"), + new ReservationTarget(501L, "P1", "same-pallet-2"))); + + assertThat(service.reserveAvailable(command)).hasSize(2); + verify(repository, times(2)).insertReservation(any()); + } + + @Test + void reservationConstraintFailureDoesNotDescribePalletAsAResource() { + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); + when(repository.lockAvailable(10001L, 88L, 2)) + .thenReturn(List.of(location(9L, "A1"), location(10L, "A2"))); + when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); + doThrow(new DataIntegrityViolationException("duplicate location reservation")) + .when(repository).insertReservation(any()); + + assertThatThrownBy(() -> service.reserveAvailable(reserveCommand())) + .hasMessageContaining("库位预占幂等键重复或库位已存在活动预占") + .hasMessageNotContaining("托盘"); + } + @Test void shouldHandleCase04() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());