diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 21db5c891..cfca8c6fa 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3054,6 +3054,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( effective_to DATETIME(3) NULL COMMENT '生效结束时间', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', remark VARCHAR(500) NULL COMMENT '规则备注', + initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', 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 '乐观锁版本号', PRIMARY KEY (id), 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) ) 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', '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', '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_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"); @@ -3761,6 +3764,14 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; 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 ( id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID', 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 ( 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组成的幂等键', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', 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;不保存敏感原文', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', 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_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_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), CONSTRAINT chk_carrier_schedule_node_standard CHECK (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')), @@ -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_to', '生效结束时间'); 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', 'rounding_mode', '舍入方式'); 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; SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、入库扣货、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index b51d107d1..9a17404d7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -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.History; 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 io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -44,7 +41,6 @@ import java.util.List; @RequestMapping("/api/platform/v1/carrier-schedule-queries") public class PlatformCarrierScheduleQueryController { @Resource private CarrierScheduleQueryService service; - @Resource private OutboundDispatchService outboundDispatchService; @Operation(summary = "查询六家船公司合规适配策略") @GetMapping("/carriers") @@ -101,23 +97,6 @@ public class PlatformCarrierScheduleQueryController { 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 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") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY", @@ -132,8 +111,7 @@ public class PlatformCarrierScheduleQueryController { @PostMapping("/{queryId}/manual-nodes") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") - @Operation(summary = "人工补录预计或实际标准船节点", - description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。") + @Operation(summary = "在跟踪任务内人工补录预计或实际标准船节点") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", resourceId = "#queryId", description = "人工补录船节点事实") public ApiResponse manualNode(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformContainerShippingNodeController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformContainerShippingNodeController.java new file mode 100644 index 000000000..910cf03e8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformContainerShippingNodeController.java @@ -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 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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 38ee6a9d4..1f7b76063 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -43,6 +43,14 @@ public final class CarrierScheduleQueryDtos { @Size(max = 128) String locationName, @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, @NotBlank @Size(max = 128) String providerEventId, @NotBlank @Size(max = 64) String providerNodeCode, @@ -97,6 +105,9 @@ public final class CarrierScheduleQueryDtos { LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + public record ContainerNodeDetail(Long containerBookingId, String currentNode, + LocalDateTime currentNodeAt, List nodes) {} + public record ExceptionFact(Long id, String providerCode, String providerEventId, String providerStatus, String exceptionCode, boolean active, LocalDateTime observedAt, String locationCode, String locationName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java index 8df45bac0..2b60fb108 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java @@ -16,6 +16,7 @@ import java.time.LocalDateTime; public class CarrierScheduleNodeFactEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long queryId; + private Long containerBookingId; private String eventKey; private String providerCode; private String providerEventId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java index f20d62918..1d040106c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java @@ -5,19 +5,29 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; @Mapper public interface CarrierScheduleNodeFactMapper extends BaseMapper { @Insert(""" 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) 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.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, CURRENT_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE id=id """) 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 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 283efa2e0..40bee31dc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -49,26 +49,26 @@ public interface CarrierScheduleQueryMapper extends BaseMapper - SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, - q.status AS queryStatus,q.latest_standard_node AS latestActualNode, - q.latest_node_observed_at AS latestActualAt, + SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, + q.status AS queryStatus,current.standard_node AS latestActualNode, + 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_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='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='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt - FROM fl_carrier_schedule_query q - JOIN ( - SELECT container_booking_id,MAX(id) AS query_id - FROM fl_carrier_schedule_query - WHERE container_booking_id IN - #{id} - GROUP BY container_booking_id - ) latest ON latest.query_id=q.id - LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL - GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at - ORDER BY q.container_booking_id + FROM fl_container_booking b + JOIN fl_carrier_schedule_node_fact current ON current.id=( + SELECT f2.id FROM fl_carrier_schedule_node_fact f2 + WHERE f2.container_booking_id=b.id AND f2.actual_at IS NOT NULL + ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1) + LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id + LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL + WHERE b.id IN + #{id} + GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at + ORDER BY b.id """) List selectContainerActualFacts( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index c83cb6239..1fdd30cd0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -79,6 +79,12 @@ public class CarrierScheduleQueryRepository { .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) .eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey)); } + public List 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) { return nodes.selectCount(new LambdaQueryWrapper() .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java index 725e45ba4..25686ee0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -42,28 +42,40 @@ public class CarrierScheduleNodeOutboxService { || !normalizedContainerNo.equals(query.getTrackingNo())) { 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); var payload = new FirstLegContainerShippingNodeRecordedV1( - string(query.getId()), string(query.getOceanBookingId()), - string(query.getContainerBookingId()), normalizedContainerNo, + string(queryId), string(oceanBookingId), string(containerBookingId), normalizedContainerNo, fact.getStandardNode(), fact.getNodeSequence(), fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); JsonNode payloadNode = objectMapper.valueToTree(payload); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); - long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion()); - String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId(); + long aggregateVersion = Math.max(0, version == null ? 0 : version); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, 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, payloadNode, payloadHash).requireValid(); InboundOutboxEntity row = new InboundOutboxEntity(); 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.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index ee7056a33..5741edc80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -8,6 +8,8 @@ import com.baiyue.common.event.ContainerNumbers; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; 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.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; @@ -48,6 +50,7 @@ import java.security.MessageDigest; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.Comparator; import java.util.HexFormat; import java.util.HashSet; import java.util.List; @@ -147,7 +150,7 @@ public class CarrierScheduleQueryService { throw exception; } 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", sha256("OUTBOUND_CONFIRM|" + containerBookingId)); if (departed != null) { @@ -164,13 +167,6 @@ public class CarrierScheduleQueryService { 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) { if (!"MANUAL_REQUIRED".equals(strategy.status())) return null; String reason = strategy.manualReason(); @@ -185,7 +181,7 @@ public class CarrierScheduleQueryService { CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); - CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(), + CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), request.sourceReference(), request.rawPayloadHash()); @@ -226,10 +222,9 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); - requireManualActualAdvance(value, node, request.actualAt()); CarrierScheduleQueryEntity before = copy(value); 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.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), "MANUAL:" + operatorId, hash(operation, id, request)); @@ -248,13 +243,42 @@ public class CarrierScheduleQueryService { return complete(id, operatorId, key, detail(after)); } - private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node, - LocalDateTime actualAt) { - if (actualAt == null || value.getLatestStandardNode() == null) return; - CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode()); - if (node.sequence() <= current.sequence()) { - throw conflict("人工实际节点必须严格晚于当前最新实际节点"); + public ContainerNodeDetail containerNodeDetail(long containerBookingId) { + if (portSchedules.containerBooking(containerBookingId) == null) { + throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); } + 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 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, @@ -500,13 +524,23 @@ public class CarrierScheduleQueryService { 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, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, 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(); - 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.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); fact.setProviderEventId(providerEventId.trim()); @@ -515,7 +549,7 @@ public class CarrierScheduleQueryService { fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); 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)) { throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java index e50cb8ec3..dfc14f0df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -18,7 +18,11 @@ public interface TerminalDrayageMapper extends BaseMapper 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 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); @@ -27,6 +31,9 @@ public interface TerminalDrayageMapper extends BaseMapper b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt FROM fl_container_booking b 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); @@ -37,6 +44,9 @@ public interface TerminalDrayageMapper extends BaseMapper FROM fl_container_booking b WHERE b.id IN #{id} + 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 """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java index 7956ff126..033ac2009 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -5,7 +5,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; 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.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; @@ -123,7 +123,7 @@ public class TerminalDrayageService { requireVersion(value, request.expectedVersion()); if (value.getFleetSupplierId() == 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())) { throw badRequest("预约提柜时间不能早于实际到港时间"); } @@ -149,7 +149,8 @@ public class TerminalDrayageService { 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("实际提柜时间不能早于当前实际船节点时间"); } 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, String action, String key, String reason, long operatorId, String operatorName) { - if (context.facts().queryId() == null || context.facts().queryVersion() == null) { - throw conflict("货柜尚未建立船节点跟踪任务"); - } - CarrierScheduleNode current = node(context.facts().latestActualNode()); + CarrierScheduleNode current = currentNode(context); 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()), - 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() : "码头拖车操作同步")); } @@ -292,7 +290,7 @@ public class TerminalDrayageService { List result = new ArrayList<>(); if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null && 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() && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() && actualPickupAt(context, value) == null) { @@ -312,7 +310,7 @@ public class TerminalDrayageService { if (context.base().unloadingCompletedAt() != null) return "UNLOADED"; if (actualPickupAt(context, value) != null) return "PICKED_UP"; 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() ? "ARRIVED" : "WAITING_FOR_ARRIVAL"; } @@ -322,7 +320,7 @@ public class TerminalDrayageService { } 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); } @@ -330,13 +328,18 @@ public class TerminalDrayageService { 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) { - 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) { 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) { @@ -362,8 +365,7 @@ public class TerminalDrayageService { } private WorkContext context(Context base, ContainerActualFacts facts) { - if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null - || facts.departedWarehouseAt() == null) return null; + if (base == null || !"ACTIVE".equals(base.recordStatus())) return null; return new WorkContext(base, facts); } diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 5689c4fc8..f8357e255 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -114,7 +114,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( 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组成的幂等键', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', 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;不保存敏感原文', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', 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_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_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), CONSTRAINT chk_carrier_schedule_node_standard CHECK (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')), diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 77e2f9018..ba98ac193 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -65,17 +65,6 @@ paths: '200': description: Stable task detail 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: parameters: [{$ref: '#/components/parameters/QueryId'}] get: diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index 97d86e172..9c50f781a 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -14,6 +14,7 @@ paths: /api/platform/v1/terminal-drayages/{containerBookingId}: get: summary: 查询单柜码头拖车详情 + description: 已确认国内离仓的货柜即使船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情;实际操作仍按到港、清关和卸柜节点校验 x-permission: platform:firstleg:terminal-drayage:list parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] responses: {'200': {$ref: '#/components/responses/ViewResponse'}} @@ -63,6 +64,7 @@ paths: /api/platform/v1/terminal-drayages/{containerBookingId}/emails: get: summary: 查询最近100封车队邮件及发信开关 + description: 已确认国内离仓的货柜允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件 x-permission: platform:firstleg:terminal-drayage:list parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] responses: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index e8b18d538..606c3e61e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -28,8 +28,6 @@ class CarrierScheduleQueryOpenApiContractTest { .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-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("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") .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("latestActualNode:") .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") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index e92640ef9..64933838e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -23,7 +23,6 @@ class PlatformCarrierScheduleQueryControllerContractTest { "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", - "ensureForManualEntry", "platform:firstleg:port-schedule-query:manual", "manualResult", "platform:firstleg:port-schedule-query:manual", "manualNode", "platform:firstleg:port-schedule-query:manual"); permissions.forEach((name, permission) -> { @@ -31,7 +30,7 @@ class PlatformCarrierScheduleQueryControllerContractTest { .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()) .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(); } }); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 4bd517f15..0614e2eec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -451,34 +451,6 @@ class CarrierScheduleQueryServiceTest { 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 void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { CarrierScheduleQueryEntity before = task(0, 3); @@ -549,7 +521,7 @@ class CarrierScheduleQueryServiceTest { existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64)); when(repository.lock(1L)).thenReturn(task); 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", "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java index 9c33e839a..dbae50224 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.terminaldrayage.controller; import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; import org.springframework.security.access.prepost.PreAuthorize; import java.nio.file.Files; @@ -50,10 +51,24 @@ class TerminalDrayageOpenApiContractTest { "platform:firstleg:terminal-drayage:pickup:confirm", "platform:firstleg:terminal-drayage:empty-return:confirm", "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", - "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays"); + "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays", + "船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情", + "允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件"); 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) { var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods()) .filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java index d826c6b86..a9a814cfa 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java @@ -92,19 +92,27 @@ class ContainerWorkbenchServiceTest { "platform:firstleg:terminal-drayage:document:edit"); when(repository.find(11L)).thenReturn(container); when(repository.departed(11L)).thenReturn(true); - when(terminalDrayages.views(List.of(11L))).thenReturn(List.of()); - when(terminalDrayages.authorize(List.of(), permissions)).thenReturn(List.of()); + var waiting = waitingDrayage(); + 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); assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.actualFacts()).isNull(); - assertThat(result.terminalDrayage()).isNull(); + assertThat(result.terminalDrayage()).isEqualTo(waiting); assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS"); verify(terminalDrayages, never()).detail(11L); 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 void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception { verifyRealEmptyReturnProjection(true, true); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java index 0520234e4..6990ee7f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.service; import com.baiyue.common.core.exception.BizException; 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.service.CarrierScheduleQueryService; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; @@ -125,8 +125,8 @@ class TerminalDrayageServiceTest { assertThat(result.status()).isEqualTo("PICKED_UP"); assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); - ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); - verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + ArgumentCaptor node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class); + verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); 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))) .isInstanceOf(BizException.class) .hasMessage("海外卸柜收货完成后才能确认还柜"); - verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); } @Test @@ -212,7 +212,7 @@ class TerminalDrayageServiceTest { assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result); 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 @@ -222,7 +222,7 @@ class TerminalDrayageServiceTest { new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null))) .isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间"); 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 @@ -243,13 +243,26 @@ class TerminalDrayageServiceTest { } @Test - void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() { + void readsDepartedContainerBeforeCarrierTrackingRecordExists() { when(mapper.contexts(List.of(11L))).thenReturn(List.of( new Context(11L, "MSCU1234567", 21L, "ACTIVE", null))); 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(); - verify(carrierSchedules).containerActualFacts(List.of(11L)); + var bulk = service.views(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 @@ -338,8 +351,8 @@ class TerminalDrayageServiceTest { assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); - ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); - verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + ArgumentCaptor node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class); + verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); assertThat(node.getValue().reason()).isEqualTo("已还柜"); assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));