diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0f0685910..afdfb9ac4 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5226,10 +5226,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) @@ -5238,8 +5235,6 @@ BEGIN OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) - OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) @@ -5253,7 +5248,6 @@ BEGIN OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) @@ -5264,7 +5258,7 @@ BEGIN OR NOT (OLD.created_at <=> NEW.created_at) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; @@ -14349,17 +14343,13 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) @@ -14367,13 +14357,13 @@ BEGIN OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 964ef0eba..723f140e5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -10,7 +10,9 @@ import java.util.List; @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); @Select("SELECT action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{bookingId} AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1") ExceptionRow exceptionOperation(@Param("bookingId") long bookingId,@Param("operatorId") long operatorId,@Param("operationId") String operationId); - @Select("SELECT * FROM fl_loading_pick_task WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") PickTaskRow pickTask(@Param("id") long id); + @Select("SELECT id,loading_plan_id AS loadingPlanId,container_booking_id AS containerBookingId," + + "task_status AS taskStatus,version FROM fl_loading_pick_task " + + "WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") PickTaskRow pickTask(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id}") int itemCount(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status IN ('PICKED','PARTIAL','SKIPPED')") int completedItemCount(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status NOT IN ('PICKED','PARTIAL','SKIPPED')") int unresolvedItemCount(@Param("id") long id); diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql index b89ba7da3..5688c6fec 100644 --- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql +++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql @@ -90,17 +90,13 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) @@ -108,13 +104,13 @@ BEGIN OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 4bfe14ab4..00405a162 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -28,23 +28,19 @@ PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')); --- Keep the container master immutable after loading; only the two document rows remain operable. +-- Keep container identity/loading facts immutable while allowing release transport data to arrive later. DROP TRIGGER IF EXISTS trg_container_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container_booking FOR EACH ROW BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.id; - IF current_plan_status='COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status='LOADING' AND ( + IF current_plan_status IN ('LOADING','COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) @@ -53,14 +49,14 @@ BEGIN OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 1da47ad6f..e1218cd04 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -584,10 +584,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) @@ -596,8 +593,6 @@ BEGIN OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) - OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) @@ -611,7 +606,6 @@ BEGIN OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) @@ -622,7 +616,7 @@ BEGIN OR NOT (OLD.created_at <=> NEW.created_at) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index 1d92a7963..a1c384ab4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.repository; import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; @@ -10,6 +12,14 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { + @Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("loading_plan_id AS loadingPlanId", + "container_booking_id AS containerBookingId", "task_status AS taskStatus", "version") + .doesNotContain("SELECT *"); + } + @Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception { String development = new ClassPathResource("db/phase3-smart-loading-schema.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java index 9811a39df..58a17c51b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java @@ -60,12 +60,16 @@ class Phase3ChannelContainerMigrationContractTest { .contains("@phase3_channel_message = MESSAGE_TEXT") .contains("渠道柜迁移失败:货柜只读触发器未恢复") .contains("渠道柜迁移失败:PDA任务只读触发器未恢复") - .contains("装柜完成后货柜全部只读,不允许修改") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") .contains("已完成PDA拣货任务不可修改") .contains("NOT (OLD.channel_code <=> NEW.channel_code)") .contains("NOT (OLD.channel_name <=> NEW.channel_name)") .contains("NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)") .contains("NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") .doesNotContain("p.plan_status='COMPLETED'"); assertThat(call).isGreaterThan(0); assertThat(containerRestore).isGreaterThan(call); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 4de276cb7..f1ce3f50f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -81,7 +81,8 @@ class SmartLoadingSqlContractTest { assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"), StandardCharsets.UTF_8)) .contains("CREATE TRIGGER trg_container_booking_before_update") - .contains("IF current_plan_status='COMPLETED' THEN") + .contains("current_plan_status IN ('LOADING','COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); } @@ -145,8 +146,11 @@ class SmartLoadingSqlContractTest { .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("装柜完成后的明细永久冻结") - .contains("装柜中仅允许修改集装箱号与航次") - .contains("装柜完成后货柜全部只读,不允许修改") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") .doesNotContain("preload_status") .doesNotContain("PRELOAD_PUSHED") .doesNotContain("PRELOADING") diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 0481250aa..9df23b3ee 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -19,10 +19,53 @@ function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) } function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } +async function releaseLoadingBooking(context, containerFlow, suffix) { + const flowOceanBookingId = id(containerFlow?.oceanBookingId) + if (!flowOceanBookingId || !id(containerFlow?.containerBookingId) || !id(containerFlow?.loadingPlanId)) return null + const flowPath = `${BASE}/${flowOceanBookingId}` + let flowResponse = await request(context, 'GET', flowPath) + let flow = data(flowResponse) + context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse, + `oceanBookingId=${flowOceanBookingId}`, OPTIONS) + const allocationMatches = flow?.containerAllocations?.some?.((item) => + id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse, + allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), + `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { + const flowRelease = await request(context, 'POST', `${flowPath}/release`, { + expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`, + vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`, + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', + reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) + context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) + flowResponse = await request(context, 'GET', flowPath) + flow = data(flowResponse) + } + context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse, + flow?.oceanBooking?.status === 'RELEASED' + && flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), + `status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) + return flow?.oceanBooking?.status === 'RELEASED' && allocationMatches ? { + id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker, + containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId), + finalStatus: 'RELEASED', + } : null +} + export async function run(context) { const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const suffix = context.marker.replace(/\D/g, '').slice(-10) + const existing = context.createdResources?.oceanBooking + if (existing?.marker === context.marker && existing?.finalStatus === 'RELEASED') { + if (!id(containerFlow?.containerBookingId) + || id(existing.containerBookingId) === id(containerFlow.containerBookingId)) return + const reconciled = await releaseLoadingBooking(context, containerFlow, suffix) + if (reconciled) context.createdResources.oceanBooking = reconciled + return + } const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) @@ -95,41 +138,8 @@ export async function run(context) { loadingPlanId: null, finalStatus: fd?.oceanBooking?.status || null, } - const flowOceanBookingId = id(containerFlow?.oceanBookingId) - if (flowOceanBookingId && id(containerFlow?.containerBookingId) && id(containerFlow?.loadingPlanId)) { - const flowPath = `${BASE}/${flowOceanBookingId}` - let flowResponse = await request(context, 'GET', flowPath) - let flow = data(flowResponse) - context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse, - `oceanBookingId=${flowOceanBookingId}`, OPTIONS) - const allocationMatches = flow?.containerAllocations?.some?.((item) => - id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse, - allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), - `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) - if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { - const flowRelease = await request(context, 'POST', `${flowPath}/release`, { - expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`, - vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`, - estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', - reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) - context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) - flowResponse = await request(context, 'GET', flowPath) - flow = data(flowResponse) - } - context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse, - flow?.oceanBooking?.status === 'RELEASED' - && flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), - `status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) - if (flow?.oceanBooking?.status === 'RELEASED') { - downstreamBooking = { - id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker, - containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId), - finalStatus: 'RELEASED', - } - } - } + const releasedLoadingBooking = await releaseLoadingBooking(context, containerFlow, suffix) + if (releasedLoadingBooking) downstreamBooking = releasedLoadingBooking context.createdResources.oceanBooking = downstreamBooking const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index da8c65d01..e3d5275d5 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -31,7 +31,8 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT assert.match(source, new RegExp(`'${status}'`)) } assert.match(source, /\.\/inbound-measurement\.mjs/) - assert.match(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/) + assert.match(source, /`\/api\/platform\/v1\/inbounds\/\$\{query\(inboundId\)\}\/measurements\/reject\?tenantId=\$\{query\(tenantId\)\}`/) + assert.doesNotMatch(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/) }) test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => { @@ -44,7 +45,7 @@ test('FBA 状态样本为订单头和每条货物行提供四个必填标识', ( test('测量状态截停发生在正确的 HTTP 边界', () => { assert.match(source, /includes\(summary\.mainStatus\)/) assert.doesNotMatch(source, /includes\(summary\.processStatus\)/) - assert.match(source, /target === 'MEASURING' && data\(response\)\?\.processStatus === 'MEASURING'/) + assert.match(source, /target === 'MEASURING' && ok\(response\) && data\(response\)\?\.measurementId/) assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/) assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/) assert.match(source, /measurements\/reject/) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 8a27e7114..f1e87501f 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -179,7 +179,7 @@ async function prepareInboundStatus(context, target) { const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`) if (isMeasurementQuery) { const response = await originalRequest.call(context, method, path, body, token, headers) - if (target === 'MEASURING' && data(response)?.processStatus === 'MEASURING') throw stop + if (target === 'MEASURING' && ok(response) && data(response)?.measurementId) throw stop return response } if (target === 'MEASURE_PENDING' && isSubmit) { @@ -192,11 +192,11 @@ async function prepareInboundStatus(context, target) { if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`) const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token) const version = Number(data(latest)?.summary?.version) - const rejected = await originalRequest.call(context, 'POST', - path.replace('/measurement/submit', '/measurements/reject'), + const rejectPath = `/api/platform/v1/inbounds/${query(inboundId)}/measurements/reject?tenantId=${query(tenantId)}` + const rejected = await originalRequest.call(context, 'POST', rejectPath, { measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` }, token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) }) - if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}`) + if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}, requestId=${rejected?.requestId || 'missing'}, traceId=${rejected?.traceId || 'missing'}`) throw stop } return originalRequest.call(context, method, path, body, token, headers) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 8b6222262..7c53f78de 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -26,6 +26,29 @@ test('PDA opens only after a real container number and uses stable plan identity assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/) }) +test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => { + const claimIndex = source.indexOf("`${PDA}/tasks/${taskId}/claim`") + const snapshotIndex = source.indexOf('inventory-snapshot?warehouseId=') + const batchIndex = source.indexOf('const batchPath = `${PDA}/tasks/${taskId}/batch`') + const completeIndex = source.indexOf('const completePath = `${PDA}/tasks/${taskId}/complete`') + const treeIndex = source.indexOf('`回查 ${sourceOrderNo} 拆分后的根聚合`') + assert.ok(claimIndex >= 0 && snapshotIndex > claimIndex && batchIndex > snapshotIndex + && completeIndex > batchIndex && treeIndex > completeIndex) + assert.match(source, /loadingPlanId: planId, containerBookingId: containerId/) + assert.match(source, /expectedVersion: Number\(inventory\.taskVersion\)/) + assert.match(source, /inventorySnapshotToken: inventory\.snapshotToken/) + assert.match(source, /action: 'PICK', pickedPackageCount: 1/) + assert.match(source, /version: Number\(claimedItem\.version\), releaseMode: 'MANUAL', locationIds: \[\]/) + assert.match(source, /'Idempotency-Key': batchKey/) + assert.match(source, /differentReplay/) + assert.match(source, /workflowStatus === 'AWAITING_REVIEW'/) + assert.match(source, /expectedTaskVersion: Number\(picked\?\.version\)/) + assert.match(source, /operationId: `\$\{operationBase\}-complete`/) + assert.match(source, /completeReplay/) + assert.match(source, /workflowStatus === 'COMPLETED'/) + assert.doesNotMatch(source, /\/items\/\$\{[^}]+\}\/pick/) +}) + test('booking freezes the source customer channel and warehouse scope', () => { assert.match(source, /customerId: source\.tenantId/) assert.match(source, /channelCode: source\.channelCode/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 558140976..f25ab4945 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -232,43 +232,83 @@ async function loadOneFragment(context, state) { const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, { expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`, }) - context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS) + const claimed = context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS) + const claimedItem = claimed?.items?.find?.((row) => id(row.id) === id(pickItem.id)) || pickItem + const inventoryResponse = await context.request('GET', + `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const inventory = context.apiSuccess(MODULE, `冻结 ${sourceOrderNo} 的 PDA 库存快照`, inventoryResponse, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 库存快照绑定稳定任务身份`, inventoryResponse, + id(inventory?.taskId) === taskId && id(inventory?.loadingPlanId) === planId + && id(inventory?.containerBookingId) === containerId && id(inventory?.warehouseId) === warehouseId + && Number(inventory?.taskVersion) === Number(claimed?.version) + && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64, + `taskId=${id(inventory?.taskId) || 'missing'}, taskVersion=${inventory?.taskVersion ?? 'missing'}, loadingPlanId=${id(inventory?.loadingPlanId) || 'missing'}, containerBookingId=${id(inventory?.containerBookingId) || 'missing'}`, OPTIONS) + if (!inventory?.snapshotToken) return null - const stalePick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, { - warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1, - operationId: `${operationBase}-pick-stale`, - }) - context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧明细版本被拒绝`, stalePick, - isConflict(stalePick) && hasTrace(stalePick), - `HTTP=${stalePick.http}, code=${codeOf(stalePick)}, requestId=${stalePick.requestId || 'missing'}, traceId=${stalePick.traceId || 'missing'}`, OPTIONS) - - const pickPayload = { - warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version), - operationId: `${operationBase}-pick`, + const batchPath = `${PDA}/tasks/${taskId}/batch` + const batchKey = `${operationBase}-batch`.slice(0, 128) + const batchPayload = { + warehouseId, loadingPlanId: planId, containerBookingId: containerId, + expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken, + items: [{ + itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1, + version: Number(claimedItem.version), releaseMode: 'MANUAL', locationIds: [], + }], } - const pick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) - const picked = context.apiSuccess(MODULE, `真实拣货并拆分 ${sourceOrderNo}`, pick, '', OPTIONS) - context.assert(MODULE, `${sourceOrderNo} 本分片只承接1箱`, pick, - picked?.items?.some?.((row) => id(row.id) === id(pickItem.id) - && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), + const staleBatch = await request(context, 'POST', batchPath, { + ...batchPayload, expectedVersion: Number(inventory.taskVersion) + 1, + }, { 'Idempotency-Key': `${operationBase}-batch-stale`.slice(0, 128) }) + context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧任务版本被拒绝且不结算`, staleBatch, + isConflict(staleBatch) && hasTrace(staleBatch), + `HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}, requestId=${staleBatch.requestId || 'missing'}, traceId=${staleBatch.traceId || 'missing'}`, OPTIONS) + + const batch = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey }) + const picked = context.apiSuccess(MODULE, `原子提交 ${sourceOrderNo} 的完整 PDA 草稿`, batch, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 批次拣出1箱并等待人工确认`, batch, + picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW' + && picked?.items?.some?.((row) => id(row.id) === id(claimedItem.id) + && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), `workflow=${picked?.workflowStatus || 'missing'}, taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS) - const replay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) - const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} 拆分幂等重放`, replay, '', OPTIONS) - context.assert(MODULE, `${sourceOrderNo} 幂等重放不重复累计`, replay, - id(replayed?.id) === taskId && replayed?.items?.some?.((row) => id(row.id) === id(pickItem.id) - && Number(row.pickedPackageCount) === 1), - `taskId=${id(replayed?.id) || 'missing'}, picked=${replayed?.items?.find?.((row) => id(row.id) === id(pickItem.id))?.pickedPackageCount ?? 'missing'}`, OPTIONS) + const replay = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey }) + const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 批次幂等重放`, replay, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 批次重放不重复累计`, replay, + id(replayed?.id) === taskId && Number(replayed?.version) === Number(picked?.version) + && replayed?.items?.some?.((row) => id(row.id) === id(claimedItem.id) + && Number(row.pickedPackageCount) === 1), + `taskId=${id(replayed?.id) || 'missing'}, taskVersion=${replayed?.version ?? 'missing'}`, OPTIONS) if (sourcePackageCount > 1) { - const differentReplay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, { - ...pickPayload, pickedPackageCount: 2, - }) + const differentReplay = await request(context, 'POST', batchPath, { + ...batchPayload, + items: [{ ...batchPayload.items[0], pickedPackageCount: 2 }], + }, { 'Idempotency-Key': batchKey }) context.addStep(MODULE, `${sourceOrderNo} 同幂等键异参被拒绝`, differentReplay, isConflict(differentReplay) && hasTrace(differentReplay), `HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS) } + const completePath = `${PDA}/tasks/${taskId}/complete` + const completePayload = { + expectedTaskVersion: Number(picked?.version), operationId: `${operationBase}-complete`.slice(0, 128), + remark: context.marker, + } + const complete = await request(context, 'POST', completePath, completePayload) + const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete, + completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' + && completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id) + && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), + `workflow=${completed?.workflowStatus || 'missing'}, taskStatus=${completed?.taskStatus || 'missing'}`, OPTIONS) + if (completed?.taskStatus !== 'COMPLETED' || completed?.workflowStatus !== 'COMPLETED') return null + + const completeReplay = await request(context, 'POST', completePath, completePayload) + const completedReplay = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 完成幂等重放`, completeReplay, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 完成重放保持同一已结算任务`, completeReplay, + id(completedReplay?.id) === taskId && Number(completedReplay?.version) === Number(completed?.version) + && completedReplay?.taskStatus === 'COMPLETED' && completedReplay?.workflowStatus === 'COMPLETED', + `taskId=${id(completedReplay?.id) || 'missing'}, taskVersion=${completedReplay?.version ?? 'missing'}`, OPTIONS) + const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId)) const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS) const candidatesAfter = await context.request('GET', candidatesPath) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index f8e223b3b..db2fe4da2 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -57,7 +57,18 @@ function oceanDraft(context, suffix, master) { } async function ensureOceanBooking(context, suffix, master) { - if (context.createdResources?.oceanBooking?.id) return context.createdResources.oceanBooking + if (context.createdResources?.oceanBooking?.id) { + const existingId = id(context.createdResources.oceanBooking.id) + const response = await request(context, 'GET', `${OCEAN}/${existingId}`) + const detail = context.apiSuccess(MODULE, '读取船期关联订舱真实柜次分配', response, + `oceanBookingId=${existingId}`, OPTIONS) + return detail?.oceanBooking ? { + id: existingId, + customerId: id(detail.oceanBooking.customerId), + allocatedContainerBookingIds: (detail.containerAllocations || []) + .map((item) => id(item?.containerBookingId)).filter(Boolean), + } : null + } const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, { 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) }) const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS) @@ -65,7 +76,11 @@ async function ensureOceanBooking(context, suffix, master) { context.assert(MODULE, '船期关联订舱客户归属明确', response, Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId), `oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS) - return oceanBookingId ? { id: oceanBookingId, customerId: master.customerId } : null + return oceanBookingId ? { + id: oceanBookingId, customerId: id(detail.oceanBooking.customerId), + allocatedContainerBookingIds: (detail.containerAllocations || []) + .map((item) => id(item?.containerBookingId)).filter(Boolean), + } : null } function scheduleData(context, suffix, oceanBookingId, containerBookingId, customerId, expired = false) { @@ -109,9 +124,15 @@ export async function run(context) { || context.createdResources?.phase3ContainerLoading const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus) ? id(flow.containerBookingId) : null - const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId - ? containerBookingId : null - const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId), reason: context.marker } + const knownAllocation = containerBookingId + && ocean.allocatedContainerBookingIds?.includes(containerBookingId) ? containerBookingId : null + if (containerBookingId && !knownAllocation) { + context.addStep(MODULE, '船期订舱真实分配包含本轮装货柜次', null, false, + `oceanBookingId=${ocean.id}, containerBookingId=${containerBookingId}, allocations=${ocean.allocatedContainerBookingIds?.join(',') || 'none'}`, + OPTIONS) + return + } + const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, ocean.customerId), reason: context.marker } const createKey = `${context.marker}-port-create`.slice(0, 128) const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS) @@ -135,7 +156,7 @@ export async function run(context) { `first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS) for (const [name, query] of [ - ['按客户查询船期', `customerId=${encodeURIComponent(master.customerId)}`], + ['按客户查询船期', `customerId=${encodeURIComponent(ocean.customerId)}`], ['按订舱查询船期', `oceanBookingId=${ocean.id}`], ...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []), ]) { @@ -225,7 +246,7 @@ export async function run(context) { const forged = await request(context, 'POST', BASE, { expectedVersion: 0, - data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, master.customerId), customerId: `${master.customerId}999` }, + data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, ocean.customerId), customerId: `${ocean.customerId}999` }, reason: '伪造客户归属', }, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) }) step(context, '伪造船期客户归属被拒绝', forged, 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 61c00b559..89870ba7d 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 @@ -279,10 +279,17 @@ test('downstream producers prefer the phase3Loading container fixture', () => { assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/) assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/) assert.match(ocean, /ocean-flow-release/) + assert.match(ocean, /existing\?\.marker === context\.marker && existing\?\.finalStatus === 'RELEASED'/) + assert.match(ocean, /const reconciled = await releaseLoadingBooking\(context, containerFlow, suffix\)/) assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/) assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/) assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/) + assert.match(serviceCenter, /if \(!allocationMatches\) return/) + const schedules = source('./phase3-port-schedules.mjs') + assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containerAllocations \|\| \[\]\)/) + assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) + assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) }) test('default acceptance order verifies loading evidence before finalizing LOADED', () => { diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index f18753a07..181d33732 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -61,9 +61,11 @@ export async function run(context) { return } if (linkContainer) { - context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, - oceanDetail?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), + const allocationMatches = oceanDetail?.containerAllocations?.some?.((item) => + id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches, `containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${oceanDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + if (!allocationMatches) return } const payload = { outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null,