优化
This commit is contained in:
@@ -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 ;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ;
|
||||
|
||||
@@ -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 ;
|
||||
|
||||
@@ -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 ;
|
||||
|
||||
+10
@@ -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);
|
||||
|
||||
+5
-1
@@ -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);
|
||||
|
||||
+7
-3
@@ -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")
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user