fix(firstleg): decouple manual container nodes from tracking

This commit is contained in:
2026-09-10 12:17:45 +08:00
parent c1a36c6f40
commit 443b01c8a8
21 changed files with 285 additions and 147 deletions
@@ -3054,6 +3054,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
effective_to DATETIME(3) NULL COMMENT '生效结束时间', effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注', remark VARCHAR(500) NULL COMMENT '规则备注',
initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -3062,6 +3063,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version), UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version),
KEY idx_charge_rule_initialization (tenant_id, initialization_key),
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to) KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; ) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
@@ -3718,6 +3720,7 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(
CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'initialization_key', "VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属' AFTER remark");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted");
CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version");
CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id"); CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id");
@@ -3761,6 +3764,14 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
DROP PROCEDURE IF EXISTS phase2_fba_add_column; DROP PROCEDURE IF EXISTS phase2_fba_add_column;
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_customer_charge_rule'
AND INDEX_NAME = 'idx_charge_rule_initialization');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_customer_charge_rule ADD INDEX idx_charge_rule_initialization (tenant_id, initialization_key)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID', id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID',
channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码', channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码',
@@ -10872,7 +10883,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属真实货柜ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
@@ -10888,9 +10900,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id),
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_carrier_schedule_node_standard CHECK CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT',
'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
@@ -13541,6 +13555,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'deleted
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_from', '生效开始时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_from', '生效开始时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_to', '生效结束时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_to', '生效结束时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'id', '客户计费规则ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'id', '客户计费规则ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'initialization_key', '客户创建执行器幂等键及补偿归属');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'remark', '规则备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'remark', '规则备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_mode', '舍入方式'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_mode', '舍入方式');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_scale', '结果保留小数位数'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_scale', '结果保留小数位数');
@@ -18599,3 +18614,21 @@ CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、入库扣货、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; '统一增量全部执行完成;父子订单、入库扣货、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
-- 货柜船运节点独立于跟踪任务:自动跟踪与人工补录共用货柜节点事实。
ALTER TABLE fl_carrier_schedule_node_fact
ADD COLUMN IF NOT EXISTS container_booking_id BIGINT UNSIGNED NULL COMMENT '所属真实货柜ID' AFTER query_id;
UPDATE fl_carrier_schedule_node_fact n
JOIN fl_carrier_schedule_query q ON q.id=n.query_id
SET n.container_booking_id=q.container_booking_id
WHERE n.container_booking_id IS NULL;
ALTER TABLE fl_carrier_schedule_node_fact MODIFY query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_node_fact' AND index_name='uk_carrier_schedule_node_event'), 'ALTER TABLE fl_carrier_schedule_node_fact DROP INDEX uk_carrier_schedule_node_event', 'SELECT 1'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE fl_carrier_schedule_node_fact MODIFY container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属真实货柜ID';
ALTER TABLE fl_carrier_schedule_node_fact ADD UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key);
SET @ddl = (SELECT IF(NOT EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_node_fact' AND index_name='idx_container_shipping_node_timeline'), 'ALTER TABLE fl_carrier_schedule_node_fact ADD KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id)', 'SELECT 1'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(NOT EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_node_fact' AND constraint_name='fk_carrier_schedule_node_container'), 'ALTER TABLE fl_carrier_schedule_node_fact ADD CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)', 'SELECT 1'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -14,9 +14,6 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,7 +41,6 @@ import java.util.List;
@RequestMapping("/api/platform/v1/carrier-schedule-queries") @RequestMapping("/api/platform/v1/carrier-schedule-queries")
public class PlatformCarrierScheduleQueryController { public class PlatformCarrierScheduleQueryController {
@Resource private CarrierScheduleQueryService service; @Resource private CarrierScheduleQueryService service;
@Resource private OutboundDispatchService outboundDispatchService;
@Operation(summary = "查询六家船公司合规适配策略") @Operation(summary = "查询六家船公司合规适配策略")
@GetMapping("/carriers") @GetMapping("/carriers")
@@ -101,23 +97,6 @@ public class PlatformCarrierScheduleQueryController {
return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
} }
@PostMapping("/containers/{containerBookingId}/ensure")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@Operation(summary = "为已确认离仓货柜补建船节点任务后进入人工补录")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ENSURE", resourceType = "CONTAINER_BOOKING",
resourceId = "#containerBookingId", description = "补建货柜船节点任务")
public ApiResponse<Detail> ensureForManualEntry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key) {
FirstlegScopeGuard.platform(user);
var outbound = outboundDispatchService.detail(containerBookingId).aggregate();
if (!"WAITING_FOR_LOADING".equals(outbound.dispatchStatus()) || outbound.oceanBookingId() == null) {
throw new BizException(ResultCode.CONFLICT, "货柜尚未确认国内出仓,不能人工补录船节点");
}
return ApiResponse.success(service.ensureForManualEntry(outbound.oceanBookingId(), containerBookingId,
user.userId(), user.username(), key));
}
@PostMapping("/{queryId}/manual-results") @PostMapping("/{queryId}/manual-results")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY", @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY",
@@ -132,8 +111,7 @@ public class PlatformCarrierScheduleQueryController {
@PostMapping("/{queryId}/manual-nodes") @PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@Operation(summary = "人工补录预计或实际标准船节点", @Operation(summary = "在跟踪任务内人工补录预计或实际标准船节点")
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实") resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Validated
@RestController
@RequestMapping("/api/platform/v1/container-shipping-nodes")
public class PlatformContainerShippingNodeController {
@Resource private CarrierScheduleQueryService service;
@GetMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<ContainerNodeDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.containerNodeDetail(containerBookingId));
}
@PostMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CONTAINER_SHIPPING_NODE_MANUAL", resourceType = "CONTAINER_BOOKING",
resourceId = "#containerBookingId", description = "人工修改货柜当前船节点")
public ApiResponse<ContainerNodeDetail> add(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ContainerManualNodeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.addContainerManualNode(containerBookingId, user.userId(),
user.username(), key, request));
}
}
@@ -43,6 +43,14 @@ public final class CarrierScheduleQueryDtos {
@Size(max = 128) String locationName, @Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {} @NotBlank @Size(max = 1000) String reason) {}
public record ContainerManualNodeRequest(@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {}
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode, public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId, @NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode, @NotBlank @Size(max = 64) String providerNodeCode,
@@ -97,6 +105,9 @@ public final class CarrierScheduleQueryDtos {
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
public record ExceptionFact(Long id, String providerCode, String providerEventId, public record ExceptionFact(Long id, String providerCode, String providerEventId,
String providerStatus, String exceptionCode, boolean active, String providerStatus, String exceptionCode, boolean active,
LocalDateTime observedAt, String locationCode, String locationName, LocalDateTime observedAt, String locationCode, String locationName,
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
public class CarrierScheduleNodeFactEntity { public class CarrierScheduleNodeFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id; @TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId; private Long queryId;
private Long containerBookingId;
private String eventKey; private String eventKey;
private String providerCode; private String providerCode;
private String providerEventId; private String providerEventId;
@@ -5,19 +5,29 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> { public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert(""" @Insert("""
INSERT INTO fl_carrier_schedule_node_fact INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, (id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, (#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3)) CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id ON DUPLICATE KEY UPDATE id=id
""") """)
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value); int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} ORDER BY observed_at,id")
List<CarrierScheduleNodeFactEntity> selectByContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_carrier_schedule_node_fact WHERE container_booking_id=#{containerBookingId} AND event_key=#{eventKey} LIMIT 1")
CarrierScheduleNodeFactEntity selectByContainerEvent(@Param("containerBookingId") long containerBookingId,
@Param("eventKey") String eventKey);
} }
@@ -49,26 +49,26 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select(""" @Select("""
<script> <script>
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,q.latest_standard_node AS latestActualNode, q.status AS queryStatus,current.standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt, current.actual_at AS latestActualAt,
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt, MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt, MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt, MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt, MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt, MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
FROM fl_carrier_schedule_query q FROM fl_container_booking b
JOIN ( JOIN fl_carrier_schedule_node_fact current ON current.id=(
SELECT container_booking_id,MAX(id) AS query_id SELECT f2.id FROM fl_carrier_schedule_node_fact f2
FROM fl_carrier_schedule_query WHERE f2.container_booking_id=b.id AND f2.actual_at IS NOT NULL
WHERE container_booking_id IN ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach> LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
GROUP BY container_booking_id LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
) latest ON latest.query_id=q.id WHERE b.id IN
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL <foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
ORDER BY q.container_booking_id ORDER BY b.id
</script> </script>
""") """)
List<ContainerActualFacts> selectContainerActualFacts( List<ContainerActualFacts> selectContainerActualFacts(
@@ -79,6 +79,12 @@ public class CarrierScheduleQueryRepository {
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey)); .eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
} }
public List<CarrierScheduleNodeFactEntity> nodesByContainer(long containerBookingId) {
return nodes.selectByContainer(containerBookingId);
}
public CarrierScheduleNodeFactEntity nodeByContainer(long containerBookingId, String eventKey) {
return nodes.selectByContainerEvent(containerBookingId, eventKey);
}
public boolean hasActualNode(long queryId, String standardNode) { public boolean hasActualNode(long queryId, String standardNode) {
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>() return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
@@ -42,28 +42,40 @@ public class CarrierScheduleNodeOutboxService {
|| !normalizedContainerNo.equals(query.getTrackingNo())) { || !normalizedContainerNo.equals(query.getTrackingNo())) {
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致"); throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致");
} }
publish(query.getId(), query.getOceanBookingId(), query.getContainerBookingId(), query.getVersion(),
fact, normalizedContainerNo, "CARRIER_NODE:" + query.getId() + ':' + fact.getId());
}
public void publishContainerNode(Long oceanBookingId, long containerBookingId,
CarrierScheduleNodeFactEntity fact, String containerNo) {
if (fact.getActualAt() == null) return;
String normalizedContainerNo = ContainerNumbers.normalizeRequired(containerNo);
publish(null, oceanBookingId, containerBookingId, 0, fact, normalizedContainerNo,
"CONTAINER_NODE:" + containerBookingId + ':' + fact.getId());
}
private void publish(Long queryId, Long oceanBookingId, Long containerBookingId, Integer version,
CarrierScheduleNodeFactEntity fact, String normalizedContainerNo, String eventKey) {
ZoneId zone = ZoneId.of(scheduleZone); ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1( var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getOceanBookingId()), string(queryId), string(oceanBookingId), string(containerBookingId), normalizedContainerNo,
string(query.getContainerBookingId()), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(), fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
JsonNode payloadNode = objectMapper.valueToTree(payload); JsonNode payloadNode = objectMapper.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion()); long aggregateVersion = Math.max(0, version == null ? 0 : version);
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
"0", "CONTAINER_BOOKING", string(query.getContainerBookingId()), "0", "CONTAINER_BOOKING", string(containerBookingId),
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
payloadNode, payloadHash).requireValid(); payloadNode, payloadHash).requireValid();
InboundOutboxEntity row = new InboundOutboxEntity(); InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(IdWorker.getId()); row.setTenantId(0L); row.setId(IdWorker.getId()); row.setTenantId(0L);
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId()); row.setAggregateType(envelope.aggregateType()); row.setAggregateId(containerBookingId);
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
@@ -8,6 +8,8 @@ import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerNodeDetail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -48,6 +50,7 @@ import java.security.MessageDigest;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Comparator;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -147,7 +150,7 @@ public class CarrierScheduleQueryService {
throw exception; throw exception;
} }
persistenceStage = "NODE_FACT"; persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId)); sha256("OUTBOUND_CONFIRM|" + containerBookingId));
if (departed != null) { if (departed != null) {
@@ -164,13 +167,6 @@ public class CarrierScheduleQueryService {
return value; return value;
} }
@Transactional
public Detail ensureForManualEntry(long oceanBookingId, long containerBookingId,
long operatorId, String operatorName, String key) {
validateKey(key);
return detail(ensureFromOutboundConfirm(null, oceanBookingId, containerBookingId, operatorId, operatorName));
}
private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) { private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) {
if (!"MANUAL_REQUIRED".equals(strategy.status())) return null; if (!"MANUAL_REQUIRED".equals(strategy.status())) return null;
String reason = strategy.manualReason(); String reason = strategy.manualReason();
@@ -185,7 +181,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleQueryEntity value = requiredLocked(id);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value); CarrierScheduleQueryEntity before = copy(value);
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(), CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node, request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash()); request.locationName(), request.sourceReference(), request.rawPayloadHash());
@@ -226,10 +222,9 @@ public class CarrierScheduleQueryService {
} }
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value); CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key); String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node, CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(), request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request)); "MANUAL:" + operatorId, hash(operation, id, request));
@@ -248,13 +243,42 @@ public class CarrierScheduleQueryService {
return complete(id, operatorId, key, detail(after)); return complete(id, operatorId, key, detail(after));
} }
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node, public ContainerNodeDetail containerNodeDetail(long containerBookingId) {
LocalDateTime actualAt) { if (portSchedules.containerBooking(containerBookingId) == null) {
if (actualAt == null || value.getLatestStandardNode() == null) return; throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
if (node.sequence() <= current.sequence()) {
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
} }
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
@Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key,
ContainerManualNodeRequest request) {
validateKey(key);
if (request.estimatedAt() == null && request.actualAt() == null) {
throw badRequest("estimatedAt和actualAt至少提供一个");
}
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
fact, normalizedContainerNo(container));
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
private ContainerNodeDetail containerNodeDetail(long containerBookingId,
List<CarrierScheduleNodeFactEntity> facts) {
CarrierScheduleNodeFactEntity current = facts.stream()
.max(Comparator.comparing(CarrierScheduleNodeFactEntity::getObservedAt)
.thenComparing(CarrierScheduleNodeFactEntity::getId)).orElse(null);
return new ContainerNodeDetail(containerBookingId, current == null ? null : current.getStandardNode(),
current == null ? null : current.getActualAt() != null ? current.getActualAt() : current.getEstimatedAt(),
facts.stream().map(this::node).toList());
} }
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
@@ -500,13 +524,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
} }
private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId, private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash);
}
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim()); fact.setProviderEventId(providerEventId.trim());
@@ -515,7 +549,7 @@ public class CarrierScheduleQueryService {
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
if (repository.insertIfAbsent(fact)) return fact; if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey()); CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实"); throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
} }
@@ -18,7 +18,11 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b FROM fl_container_booking b
WHERE b.id=#{containerBookingId} FOR UPDATE WHERE b.id=#{containerBookingId}
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
FOR UPDATE
""") """)
Context lockContext(@Param("containerBookingId") long containerBookingId); Context lockContext(@Param("containerBookingId") long containerBookingId);
@@ -27,6 +31,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
FROM fl_container_booking b FROM fl_container_booking b
WHERE b.id=#{containerBookingId} WHERE b.id=#{containerBookingId}
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
""") """)
Context context(@Param("containerBookingId") long containerBookingId); Context context(@Param("containerBookingId") long containerBookingId);
@@ -37,6 +44,9 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
FROM fl_container_booking b FROM fl_container_booking b
WHERE b.id IN WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach> <foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
ORDER BY b.id ORDER BY b.id
</script> </script>
""") """)
@@ -5,7 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
@@ -123,7 +123,7 @@ public class TerminalDrayageService {
requireVersion(value, request.expectedVersion()); requireVersion(value, request.expectedVersion());
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队"); if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间"); if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
if (context.facts().arrivedDestinationPortAt() != null if (context.facts() != null && context.facts().arrivedDestinationPortAt() != null
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) { && request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
throw badRequest("预约提柜时间不能早于实际到港时间"); throw badRequest("预约提柜时间不能早于实际到港时间");
} }
@@ -149,7 +149,8 @@ public class TerminalDrayageService {
throw conflict("选择码头车队并预约提柜后才能确认实际提柜"); throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
} }
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认"); if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
if (context.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().latestActualAt())) { if (context.facts() != null && context.facts().latestActualAt() != null
&& request.actualAt().isBefore(context.facts().latestActualAt())) {
throw badRequest("实际提柜时间不能早于当前实际船节点时间"); throw badRequest("实际提柜时间不能早于当前实际船节点时间");
} }
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
@@ -192,14 +193,11 @@ public class TerminalDrayageService {
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt, private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
String action, String key, String reason, long operatorId, String operatorName) { String action, String key, String reason, long operatorId, String operatorName) {
if (context.facts().queryId() == null || context.facts().queryVersion() == null) { CarrierScheduleNode current = currentNode(context);
throw conflict("货柜尚未建立船节点跟踪任务");
}
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= target.sequence()) return; if (current != null && current.sequence() >= target.sequence()) return;
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName, carrierSchedules.addContainerManualNode(context.base().containerBookingId(), operatorId, operatorName,
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()), "td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null, new ContainerManualNodeRequest(target.name(), LocalDateTime.now(clock), null,
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步")); actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
} }
@@ -292,7 +290,7 @@ public class TerminalDrayageService {
List<String> result = new ArrayList<>(); List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL"); && actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = node(context.facts().latestActualNode()); CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
&& actualPickupAt(context, value) == null) { && actualPickupAt(context, value) == null) {
@@ -312,7 +310,7 @@ public class TerminalDrayageService {
if (context.base().unloadingCompletedAt() != null) return "UNLOADED"; if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP"; if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
CarrierScheduleNode current = node(context.facts().latestActualNode()); CarrierScheduleNode current = currentNode(context);
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
? "ARRIVED" : "WAITING_FOR_ARRIVAL"; ? "ARRIVED" : "WAITING_FOR_ARRIVAL";
} }
@@ -322,7 +320,7 @@ public class TerminalDrayageService {
} }
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) { private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
CarrierScheduleNode current = node(context.facts().latestActualNode()); CarrierScheduleNode current = currentNode(context);
if (current == null || current.sequence() < required.sequence()) throw conflict(message); if (current == null || current.sequence() < required.sequence()) throw conflict(message);
} }
@@ -330,13 +328,18 @@ public class TerminalDrayageService {
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null; return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
} }
private CarrierScheduleNode currentNode(WorkContext context) {
return context.facts() == null ? null : node(context.facts().latestActualNode());
}
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) { private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt(); return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt()
: context.facts() == null ? null : context.facts().pickedUpAt();
} }
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) { private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualEmptyReturnAt() != null return value != null && value.getActualEmptyReturnAt() != null
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt(); ? value.getActualEmptyReturnAt() : context.facts() == null ? null : context.facts().emptyReturnedAt();
} }
private WorkContext requiredContext(long id) { private WorkContext requiredContext(long id) {
@@ -362,8 +365,7 @@ public class TerminalDrayageService {
} }
private WorkContext context(Context base, ContainerActualFacts facts) { private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
|| facts.departedWarehouseAt() == null) return null;
return new WorkContext(base, facts); return new WorkContext(base, facts);
} }
@@ -114,7 +114,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属真实货柜ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
@@ -130,9 +131,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id),
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_carrier_schedule_node_standard CHECK CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT',
'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')),
@@ -65,17 +65,6 @@ paths:
'200': '200':
description: Stable task detail description: Stable task detail
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:
post:
summary: Idempotently ensure the carrier task for a departed container before manual entry
x-permission: platform:firstleg:port-schedule-query:manual
parameters:
- {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {$ref: '#/components/parameters/IdempotencyKey'}
responses:
'200':
description: Existing or newly created task detail; platform-owned consolidated bookings use customerId 0
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/{queryId}/history: /api/platform/v1/carrier-schedule-queries/{queryId}/history:
parameters: [{$ref: '#/components/parameters/QueryId'}] parameters: [{$ref: '#/components/parameters/QueryId'}]
get: get:
@@ -14,6 +14,7 @@ paths:
/api/platform/v1/terminal-drayages/{containerBookingId}: /api/platform/v1/terminal-drayages/{containerBookingId}:
get: get:
summary: 查询单柜码头拖车详情 summary: 查询单柜码头拖车详情
description: 已确认国内离仓的货柜即使船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情;实际操作仍按到港、清关和卸柜节点校验
x-permission: platform:firstleg:terminal-drayage:list x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {$ref: '#/components/responses/ViewResponse'}} responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
@@ -63,6 +64,7 @@ paths:
/api/platform/v1/terminal-drayages/{containerBookingId}/emails: /api/platform/v1/terminal-drayages/{containerBookingId}/emails:
get: get:
summary: 查询最近100封车队邮件及发信开关 summary: 查询最近100封车队邮件及发信开关
description: 已确认国内离仓的货柜允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件
x-permission: platform:firstleg:terminal-drayage:list x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: responses:
@@ -28,8 +28,6 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("Estimated facts are never returned or inferred") .contains("Estimated facts are never returned or inferred")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
.contains("/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:")
.contains("platform-owned consolidated bookings use customerId 0")
.contains("x-permission: platform:firstleg:port-schedule-query:manual") .contains("x-permission: platform:firstleg:port-schedule-query:manual")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT") .contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT")
@@ -40,8 +38,6 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks") .contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
.contains("latestActualNode:") .contains("latestActualNode:")
.contains("latestActualAt:") .contains("latestActualAt:")
.contains("manual actual node is equal to or earlier than latestActualNode")
.contains("strictly greater sequence than latestActualNode")
.contains("internal execution fields are intentionally absent") .contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]") .doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
@@ -23,7 +23,6 @@ class PlatformCarrierScheduleQueryControllerContractTest {
"detail", "platform:firstleg:port-schedule-query:list", "detail", "platform:firstleg:port-schedule-query:list",
"history", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list",
"create", "platform:firstleg:port-schedule-query:create", "create", "platform:firstleg:port-schedule-query:create",
"ensureForManualEntry", "platform:firstleg:port-schedule-query:manual",
"manualResult", "platform:firstleg:port-schedule-query:manual", "manualResult", "platform:firstleg:port-schedule-query:manual",
"manualNode", "platform:firstleg:port-schedule-query:manual"); "manualNode", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> { permissions.forEach((name, permission) -> {
@@ -31,7 +30,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value()) assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')"); .isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("ensureForManualEntry") || name.equals("manualResult") || name.equals("manualNode")) { if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
} }
}); });
@@ -451,34 +451,6 @@ class CarrierScheduleQueryServiceTest {
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
} }
@Test
void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
task.setLatestNodeObservedAt(NOW.minusDays(1));
when(repository.lock(1L)).thenReturn(task);
ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null,
NOW.plusDays(1), null, "Los Angeles", "重复确认提柜");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late",
new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null,
"Los Angeles", "倒序补录可提柜")))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class));
verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString());
verify(nodeOutbox, never()).publish(any(), any(), anyString());
}
@Test @Test
void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() {
CarrierScheduleQueryEntity before = task(0, 3); CarrierScheduleQueryEntity before = task(0, 3);
@@ -549,7 +521,7 @@ class CarrierScheduleQueryServiceTest {
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64)); existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
when(repository.lock(1L)).thenReturn(task); when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false); when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false);
when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing); when(repository.nodeByContainer(task.getContainerBookingId(), "ZIM:evt-duplicate")).thenReturn(existing);
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64)); "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.terminaldrayage.controller; package com.baiyue.firstleg.terminaldrayage.controller;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Select;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import java.nio.file.Files; import java.nio.file.Files;
@@ -50,10 +51,24 @@ class TerminalDrayageOpenApiContractTest {
"platform:firstleg:terminal-drayage:pickup:confirm", "platform:firstleg:terminal-drayage:pickup:confirm",
"platform:firstleg:terminal-drayage:empty-return:confirm", "platform:firstleg:terminal-drayage:empty-return:confirm",
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays"); "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
assertThat(yaml).contains("description: 卸柜完成时间加72小时"); assertThat(yaml).contains("description: 卸柜完成时间加72小时");
} }
@Test
void terminalDrayageContextsUseConfirmedOutboundAsTheOverseasBoundary() throws Exception {
var mapper = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
for (String methodName : java.util.List.of("context", "lockContext", "contexts")) {
var method = java.util.Arrays.stream(mapper.getDeclaredMethods())
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
String sql = String.join("\n", method.getAnnotation(Select.class).value());
assertThat(sql).contains("FROM fl_outbound_dispatch", "d.confirmed_at IS NOT NULL",
"d.reversed_at IS NULL", "d.status='WAITING_FOR_LOADING'");
}
}
private static void assertPermission(String methodName, String permission) { private static void assertPermission(String methodName, String permission) {
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods()) var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow(); .filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
@@ -92,19 +92,27 @@ class ContainerWorkbenchServiceTest {
"platform:firstleg:terminal-drayage:document:edit"); "platform:firstleg:terminal-drayage:document:edit");
when(repository.find(11L)).thenReturn(container); when(repository.find(11L)).thenReturn(container);
when(repository.departed(11L)).thenReturn(true); when(repository.departed(11L)).thenReturn(true);
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of()); var waiting = waitingDrayage();
when(terminalDrayages.authorize(List.of(), permissions)).thenReturn(List.of()); when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(waiting));
when(terminalDrayages.authorize(List.of(waiting), permissions)).thenReturn(List.of(waiting));
var result = service.overseasDetail(11L, permissions); var result = service.overseasDetail(11L, permissions);
assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.actualFacts()).isNull(); assertThat(result.actualFacts()).isNull();
assertThat(result.terminalDrayage()).isNull(); assertThat(result.terminalDrayage()).isEqualTo(waiting);
assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS"); assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS");
verify(terminalDrayages, never()).detail(11L); verify(terminalDrayages, never()).detail(11L);
verifyNoInteractions(carrierSchedules); verifyNoInteractions(carrierSchedules);
} }
private static com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View waitingDrayage() {
return new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View(
11L, "MSCU1234567", 21L, "WAITING_FOR_ARRIVAL", 0, null, null, null,
null, null, null, null, new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning(
"NOT_APPLICABLE", null, 0, 0), List.of(), null, null);
}
@Test @Test
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception { void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
verifyRealEmptyReturnProjection(true, true); verifyRealEmptyReturnProjection(true, true);
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
@@ -125,8 +125,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("PICKED_UP"); assertThat(result.status()).isEqualTo("PICKED_UP");
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class); ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
} }
@@ -144,7 +144,7 @@ class TerminalDrayageServiceTest {
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessage("海外卸柜收货完成后才能确认还柜"); .hasMessage("海外卸柜收货完成后才能确认还柜");
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
} }
@Test @Test
@@ -212,7 +212,7 @@ class TerminalDrayageServiceTest {
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result); assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(carrierSchedules, times(1)).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); verify(carrierSchedules, times(1)).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
} }
@Test @Test
@@ -222,7 +222,7 @@ class TerminalDrayageServiceTest {
new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null))) new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null)))
.isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间"); .isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间");
verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
} }
@Test @Test
@@ -243,13 +243,26 @@ class TerminalDrayageServiceTest {
} }
@Test @Test
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() { void readsDepartedContainerBeforeCarrierTrackingRecordExists() {
when(mapper.contexts(List.of(11L))).thenReturn(List.of( when(mapper.contexts(List.of(11L))).thenReturn(List.of(
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null))); new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
when(mapper.findAll(List.of(11L))).thenReturn(List.of()); when(mapper.findAll(List.of(11L))).thenReturn(List.of());
when(mapper.context(11L)).thenReturn(
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null));
when(mapper.find(11L)).thenReturn(null);
assertThat(service.views(List.of(11L))).isEmpty(); var bulk = service.views(List.of(11L));
verify(carrierSchedules).containerActualFacts(List.of(11L)); var detail = service.detail(11L);
assertThat(bulk).singleElement().satisfies(view -> {
assertThat(view.status()).isEqualTo("WAITING_FOR_ARRIVAL");
assertThat(view.actualFacts()).isNull();
assertThat(view.allowedActions()).isEmpty();
});
assertThat(detail.status()).isEqualTo("WAITING_FOR_ARRIVAL");
assertThat(detail.actualFacts()).isNull();
assertThat(detail.allowedActions()).isEmpty();
verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L));
} }
@Test @Test
@@ -338,8 +351,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class); ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(node.getValue().reason()).isEqualTo("已还柜"); assertThat(node.getValue().reason()).isEqualTo("已还柜");
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0)); assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));