diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f41a55170..4ff0f6c5b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -20,6 +20,8 @@ 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 +船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。 + `overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。 `overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 2a65087ad..0b9f0864c 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact ( location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称', source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用', raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空', + operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), 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 1f7b76063..f7ddb3729 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 @@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos { String providerNodeCode, String standardNode, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, - String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + String sourceReference, String rawPayloadHash, LocalDateTime createdAt, + String operatorName) {} public record ContainerNodeDetail(Long containerBookingId, String currentNode, LocalDateTime currentNodeAt, List nodes) {} 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 2b60fb108..d29db66ef 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 @@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity { private String locationName; private String sourceReference; private String rawPayloadHash; + private Long operatorId; + private String operatorName; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; } 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 2769556f7..dadef5640 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 @@ -11,14 +11,15 @@ import java.util.List; @Mapper public interface CarrierScheduleNodeFactMapper extends BaseMapper { + /** 保存不可变节点事实及执行该操作的账号快照。 */ @Insert(""" INSERT INTO fl_carrier_schedule_node_fact (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,operator_id,operator_name,created_at) VALUES (#{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}, + #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName}, CURRENT_TIMESTAMP(3)) """) int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value); 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 6f05c6b98..269317e32 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 @@ -111,7 +111,7 @@ public class CarrierScheduleQueryService { public long customerId(long id) { return required(id).getCustomerId(); } - /** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ + /** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */ @Transactional public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId, long containerBookingId, @@ -161,7 +161,7 @@ public class CarrierScheduleQueryService { persistenceStage = "NODE_FACT"; CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", - sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName); if (departed != null) { persistenceStage = "CROSS_REGION_OUTBOX"; nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); @@ -185,6 +185,7 @@ public class CarrierScheduleQueryService { + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); } + /** 保存船司自动采集事实,操作人留空以区别人工补录。 */ @Transactional public Detail applyProviderObservation(long id, ProviderObservationRequest request) { CarrierScheduleQueryEntity value = requiredLocked(id); @@ -193,7 +194,7 @@ public class CarrierScheduleQueryService { 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()); + request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null); if (fact == null) { return detail(value); } @@ -220,6 +221,7 @@ public class CarrierScheduleQueryService { return detail(value); } + /** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */ @Transactional public Detail addManualNode(long id, long operatorId, String operatorName, String key, ManualNodeRequest request) { @@ -238,7 +240,7 @@ public class CarrierScheduleQueryService { 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)); + "MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName); if (fact == null) { return complete(id, operatorId, key, detail(value)); } @@ -261,6 +263,7 @@ public class CarrierScheduleQueryService { return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId)); } + /** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */ @Transactional public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId, String operatorName, String key, @@ -280,7 +283,8 @@ public class CarrierScheduleQueryService { CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId, "MANUAL", providerEventId, node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), - request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash); + request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash, + operatorId, operatorName); if (fact != null && query != null) { CarrierScheduleQueryEntity before = copy(query); requireManualActualAdvance(query, node, request.actualAt()); @@ -546,11 +550,12 @@ public class CarrierScheduleQueryService { repository.histories(value.getId()).stream().map(this::history).toList()); } + /** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */ private NodeFact node(CarrierScheduleNodeFactEntity value) { return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), - value.getRawPayloadHash(), value.getCreatedAt()); + value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName()); } private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { @@ -565,21 +570,23 @@ public class CarrierScheduleQueryService { value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); } + /** 为查询任务追加节点事实并透传人工操作账号。 */ 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) { + String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) { return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId, providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName, - sourceReference, rawPayloadHash); + sourceReference, rawPayloadHash, operatorId, operatorName); } + /** 节点与操作人快照同次入库,重复事件保留首次记录。 */ 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) { + String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) { CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); @@ -589,6 +596,7 @@ public class CarrierScheduleQueryService { fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); + fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName)); if (repository.insertIfAbsent(fact)) return fact; CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey()); if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) { 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 428238bd6..cd334c3bb 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 @@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( location_name VARCHAR(128) NULL COMMENT '节点地点名称', source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + operator_id BIGINT UNSIGNED NULL COMMENT '节点操作人账号ID;自动采集时为空', + operator_name VARCHAR(128) NULL COMMENT '节点操作人用户名快照;自动采集时为空', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), 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 d4593be35..eb3fc3c97 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -234,6 +234,7 @@ components: locationCode: {type: string, maxLength: 32, nullable: true} locationName: {type: string, maxLength: 128, nullable: true} sourceReference: {type: string, maxLength: 500, nullable: true} + operatorName: {type: string, maxLength: 128, nullable: true, description: '节点操作人用户名快照;人工补录和确认出仓时记录,自动采集为空'} rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} createdAt: {type: string, format: date-time} ExceptionFact: 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 606c3e61e..4f07d90ae 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 @@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQueryOpenApiContractTest { + /** 两个节点读取入口使用相同的操作用户名响应契约。 */ + @Test + void nodeFactsExposeTheRecordedOperatorUsername() throws Exception { + String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + String node = yaml.substring(yaml.indexOf(" NodeFact:"), yaml.indexOf(" ExceptionFact:")); + assertThat(node).contains("operatorName: {type: string, maxLength: 128, nullable: true") + .contains("节点操作人用户名快照"); + } + @Test void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception { String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 13ff0dbed..2b7f7dea7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -16,6 +16,23 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQuerySqlContractTest { + /** 模块与部署基线共同保存节点署名,Mapper 同次写入两项快照。 */ + @Test + void nodeFactsPersistOperatorSnapshotInBothBaselines() throws Exception { + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + for (String source : List.of(moduleSql(), unified)) { + String lower = source.toLowerCase(java.util.Locale.ROOT); + int start = lower.indexOf("fl_carrier_schedule_node_fact ("); + String table = lower.substring(start, lower.indexOf("engine=innodb", start)); + assertThat(table).contains("operator_id bigint unsigned", "operator_name varchar(128)"); + } + String insert = String.join("\n", com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper.class + .getMethod("insertStrict", com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class) + .getAnnotation(org.apache.ibatis.annotations.Insert.class).value()); + assertThat(insert).contains("operator_id,operator_name", "#{value.operatorId},#{value.operatorName}"); + } + @Test void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception { JdbcDataSource database = new JdbcDataSource(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java index 8c9763e94..a0e49163c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleExceptionPersistenceTest.java @@ -133,14 +133,21 @@ class CarrierScheduleExceptionPersistenceTest { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8); } + /** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */ @Test void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() { var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( "ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles", "synthetic container manual fact"); - service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); + var detail = service.addContainerManualNode(3,10001,"platform_admin","original-body-key",body); + assertThat(detail.nodes().stream().filter(node -> "MANUAL".equals(node.providerCode())).toList()) + .singleElement().satisfies(node -> assertThat(node.operatorName()).isEqualTo("platform_admin")); + assertThat(jdbc.queryForObject("SELECT operator_id FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", Long.class)) + .isEqualTo(10001L); + assertThat(jdbc.queryForObject("SELECT operator_name FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", String.class)) + .isEqualTo("platform_admin"); var saved = allSnapshot(); - service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); + service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body); assertThat(allSnapshot()).isEqualTo(saved); assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key", new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( 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 52e4846b0..4ddebb302 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 @@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移"); } + /** 出仓节点记录确认人的账号,供两个货柜详情展示。 */ @Test void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); @@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest { when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); - CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"); + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "platform_admin"); assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getCarrierCode()).isEqualTo("COSCO"); @@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest { assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); - assertThat(result.getCreatedBy()).isZero(); - assertThat(result.getCreatedByName()).isEqualTo("SYSTEM"); - assertThat(result.getUpdatedBy()).isZero(); - assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM"); + assertThat(result.getCreatedBy()).isEqualTo(7L); + assertThat(result.getCreatedByName()).isEqualTo("platform_admin"); + assertThat(result.getUpdatedBy()).isEqualTo(7L); + assertThat(result.getUpdatedByName()).isEqualTo("platform_admin"); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); verify(nodeOutbox).publish(eq(result), argThat(fact -> "FIRSTLEG".equals(fact.getProviderCode()) && "OUTBOUND_CONFIRM".equals(fact.getProviderEventId()) && "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode()) && "DEPARTED_WAREHOUSE".equals(fact.getStandardNode()) + && Long.valueOf(7L).equals(fact.getOperatorId()) + && "platform_admin".equals(fact.getOperatorName()) && fact.getActualAt() != null), eq("ZCSU7881970")); verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> "AUTO_CREATE".equals(history.getAction()) && history.getFromVersion() == null && Integer.valueOf(0).equals(history.getToVersion()) - && Long.valueOf(0L).equals(history.getOperatorId()) - && "SYSTEM".equals(history.getOperatorName()) + && Long.valueOf(7L).equals(history.getOperatorId()) + && "platform_admin".equals(history.getOperatorName()) && "国内确认出仓自动创建".equals(history.getReason()))); } @@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest { verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); } + /** 自动采集推进原节点,但不冒用平台操作账号。 */ @Test void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { CarrierScheduleQueryEntity task = task(0, 0); @@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest { assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT"); assertThat(result.nodes()).isEmpty(); + verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> + fact.getOperatorId() == null && fact.getOperatorName() == null)); } @Test @@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest { verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970")); } + /** 人工实际节点保留操作账号及既有还柜幂等行为。 */ @Test void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { CarrierScheduleQueryEntity task = task(0, 3); @@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest { assertThat(result.query().query().status()).isEqualTo("STOPPED"); assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED"); + verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> + Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName()))); verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); } @@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest { verify(repository).updateState(before, 3, 7L, "admin"); } + /** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */ @Test void containerManualNodeJoinsAndAdvancesExistingCarrierTask() { CarrierScheduleQueryEntity task = task(0, 8); @@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest { "PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup")); verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId()) - && fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode()))); + && fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode()) + && Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName()))); verify(repository).updateState(task, 8, 7L, "admin"); verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> "MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())