diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java new file mode 100644 index 000000000..4fa153600 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java @@ -0,0 +1,60 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; +import java.util.Map; + +/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */ +@Mapper +public interface FinanceFeeSourceReadMapper { + String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))"; + String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END"; + String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)"; + String FILTER = " FROM fin_fee_source f WHERE 1=1 " + + "" + + " AND f.status<>'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" + + " WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" + + " AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)" + + "" + + " AND f.customer_id=#{customerId}" + + " AND f.direction=#{direction}" + + " AND f.review_status=#{reviewStatus}" + + " AND " + STATUS + "=#{status}" + + " AND " + GENERATED + "=#{statementGenerated}" + + " AND " + BUSINESS_DATE + ">=#{dateFrom}" + + " AND " + BUSINESS_DATE + "<=#{dateTo}" + + " AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" + + " OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))"; + + /** 筛选和计数共用当前版本条件,不能先分页再去重。 */ + @Select("") + long count(Map filters); + + /** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */ + @Select("") + List> list(Map filters); + + String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" + + " AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" + + " AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" + + " AND f.source_fee_version<=anchor.source_fee_version"; + + /** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */ + @Select("") + List> versions(@Param("customerId") long customerId, @Param("id") long id); + + /** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */ + @Select("") + List> audits(@Param("customerId") long customerId, @Param("id") long id); +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java new file mode 100644 index 000000000..c5862944e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java @@ -0,0 +1,145 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog; +import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper; +import com.baiyue.finance.service.FinanceEventException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.LinkedCaseInsensitiveMap; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */ +@Repository +public class FinanceFeeSourceReadRepository { + private final FinanceFeeSourceReadMapper mapper; + private final ObjectMapper json; + + /** 注入正式持久化入口与费用快照解析器。 */ + public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) { + this.mapper = mapper; + this.json = json; + } + + /** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */ + public Map filters(Long customerId, String direction, String status, String reviewStatus, + Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) { + Map values = new HashMap<>(); + values.put("customerId", customerId); values.put("direction", direction); values.put("status", status); + values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated); + values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword); + values.put("limit", size); values.put("offset", (page - 1) * size); + return values; + } + + /** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */ + public long count(Map filters) { return mapper.count(filters); } + + /** 当前费用映射沿用原币、数量、客户等正式字段。 */ + public List list(Map filters) { + return mapper.list(filters).stream().map(this::summary).toList(); + } + + /** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */ + public List logs(long customerId, long id) { + List result = new ArrayList<>(); + Map previous = null; + for (Map raw : mapper.versions(customerId, id)) { + Map row = row(raw); + String version = "来源版本 " + row.get("source_fee_version"); + String amount = amount(row) + " " + row.get("currency_code"); + // 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。 + result.add(new OperationLog(time(row.get("created_at")), + previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST", + "系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount + : "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version") + + ";金额 " + amount(previous) + " → " + amount)); + previous = row; + } + // 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。 + java.util.Collections.reverse(result); + for (Map raw : mapper.audits(customerId, id)) { + Map row = row(raw); + String remark = text(row.get("reason")); + JsonNode before = parse(row.get("before_data")); + JsonNode after = parse(row.get("after_data")); + if (before.hasNonNull("amount") && after.hasNonNull("amount") + && before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) { + remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText() + + " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY"); + } + result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")), + text(row.get("operator_name_snapshot")), "SUCCESS", remark)); + } + // 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。 + result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder()))); + return List.copyOf(result); + } + + /** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */ + private FeeSourceSummary summary(Map raw) { + Map row = row(raw); + JsonNode snapshot = parse(row.get("source_snapshot")); + BigDecimal amount = decimal(row.get("amount")); + BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount); + BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity")); + BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0 + ? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null); + return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")), + first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")), + ((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")), + text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"), + nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"), + nullableLong(row.get("container_id")), text(snapshot, "containerNo"), + first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")), + quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")), + amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))), + originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), + first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")), + Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0); + } + + /** 保留不同数据库驱动的列名大小写读取语义。 */ + private static Map row(Map source) { + Map result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result; + } + /** 解析不可变快照,损坏数据失败关闭。 */ + private JsonNode parse(Object value) { + try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); } + catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); } + } + /** 格式化记录金额,保留定点精度。 */ + private static String amount(Map row) { return decimal(row.get("amount")).toPlainString(); } + /** 读取驱动返回的标准时间。 */ + private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; } + /** 读取数字主键,仅用于数据引用。 */ + private static long number(Object value) { return ((Number) value).longValue(); } + /** 保留未关联的可空引用。 */ + private static Long nullableLong(Object value) { return value == null ? null : number(value); } + /** 只接受合法的正整型供应商引用。 */ + private static Long positiveLong(String value) { + try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; } + catch (NumberFormatException failure) { return null; } + } + /** 读取可空金额,不经过浮点转换。 */ + private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); } + /** 读取可空业务日期。 */ + private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); } + /** 保持空字段的缺失语义。 */ + private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); } + /** 读取快照字段。 */ + private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); } + /** 优先使用来源字段,其次使用已有正式默认值。 */ + private static T first(T value, T fallback) { return value == null ? fallback : value; } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index e9d3f6e1f..856ae61d7 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -1,10 +1,10 @@ package com.baiyue.finance.service; +import com.baiyue.finance.repository.FinanceFeeSourceReadRepository; import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; -import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest; @@ -44,19 +44,19 @@ public class FinancePhase1OperationService { private final ObjectMapper objectMapper; private final FirstlegFeeSourceClient firstleg; private final String internalToken; + private final FinanceFeeSourceReadRepository feeSources; + /** 注入费用来源查询及现有业务依赖。 */ @Autowired public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper, FirstlegFeeSourceClient firstleg, - @Value("${baiyue.security.internal-service-token}") String internalToken) { + @Value("${baiyue.security.internal-service-token}") String internalToken, + FinanceFeeSourceReadRepository feeSources) { this.jdbc = jdbc; this.objectMapper = objectMapper; this.firstleg = firstleg; this.internalToken = internalToken; - } - - public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) { - this(jdbc, objectMapper, null, ""); + this.feeSources = feeSources; } public PageEnvelope listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) { @@ -163,6 +163,7 @@ public class FinancePhase1OperationService { return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size()); } + /** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */ public PageEnvelope listFinanceFeeSources(int page, int pageSize, Long customerId, String direction, String sourceStatus, String reviewStatus, @@ -178,34 +179,9 @@ public class FinancePhase1OperationService { List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); String normalizedSourceStatus = normalized(sourceStatus, List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); - String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)"; - String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END"; - String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')"; - StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1"); - List args = new ArrayList<>(); - if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); } - if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); } - if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); } - if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); } - if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); } - if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); } - if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); } - String normalizedKeyword = blankToNull(keyword); - if (normalizedKeyword != null) { - where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))"); - for (int i = 0; i < 6; i++) args.add(normalizedKeyword); - } - Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray()); - List queryArgs = new ArrayList<>(args); - queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize); - String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" + - where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?"; - List rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray()); - return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total); + Map filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus, + normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize); + return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters)); } public FeeSourceDetail getFinanceFeeSource(long feeSourceId) { @@ -339,6 +315,7 @@ public class FinancePhase1OperationService { return after; } + /** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */ private FeeSourceDetail detail(ResultSet rs) throws SQLException { JsonNode snapshot = parse(rs.getString("source_snapshot")); long id = rs.getLong("id"); @@ -356,33 +333,7 @@ public class FinancePhase1OperationService { amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated, - rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id)); - } - - private FeeSourceSummary summary(ResultSet rs) throws SQLException { - JsonNode snapshot = parse(rs.getString("source_snapshot")); - boolean generated = rs.getBoolean("statement_generated"); - BigDecimal quantity = decimal(snapshot, "chargeableQuantity"); - BigDecimal amount = money(rs, "amount"); - BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount); - BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0 - ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null); - return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"), - firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), - rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), - jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"), - nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), - text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), - quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"), - amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, - firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), - defaultString(rs.getString("review_status"), "DRAFT"), - rs.getString("effective_status"), generated); - } - - private List logs(long customerId, long id) { - return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) -> - new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id); + rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id)); } private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 1c052ca3f..ff8b8a6ca 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -17,6 +17,7 @@ class FinanceContractTest { .getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class)); } + /** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */ @Test void controllersUseFrozenFinancePaths() throws Exception { assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]); @@ -40,7 +41,8 @@ class FinanceContractTest { assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())")); String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java")); - assertTrue(operations.contains("bl.source_id=f.id")); + String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java")); + assertTrue(feeReads.contains("bl.source_id=f.id")); assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")")); assertFalse(operations.contains("bl.source_id=f.source_fee_id")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java new file mode 100644 index 000000000..c48e46ab7 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java @@ -0,0 +1,141 @@ +package com.baiyue.finance; + +import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper; +import com.baiyue.finance.repository.FinanceFeeSourceReadRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 隔离数据库执行正式 Mapper,验证版本归并与历史不会改变金额和客户归属。 */ +public class FinanceFeeSourceReadMapperTest { + /** 应收和应付均以来源身份替换当前展示,同名费用、其他来源类型和系统保持独立。 */ + @ParameterizedTest + @ValueSource(strings = {"RECEIVABLE", "PAYABLE"}) + void currentProjectionAndHistoryKeepStableFeeIdentity(String direction) { + var data = new DriverManagerDataSource("jdbc:h2:mem:fee-current-" + UUID.randomUUID() + ";MODE=MySQL", "sa", ""); + var jdbc = new JdbcTemplate(data); + // 保持一个会话存活;Mapper 使用同一隔离数据库,绝不访问共享测试环境。 + try (var connection = data.getConnection()) { + jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'"); + jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'"); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)"); + jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)"); + insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00"); + insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00"); + insert(jdbc, 3, "FIRSTLEG", "INBOUND_FEE", 701, 1, 9, direction, "AVAILABLE", "80.00"); + insert(jdbc, 4, "FIRSTLEG", "INBOUND_FEE", 702, 1, 9, direction, "AVAILABLE", "50.00"); + insert(jdbc, 5, "FIRSTLEG", "INBOUND_FEE", 702, 2, 9, direction, "VOIDED", "70.00"); + insert(jdbc, 6, "LASTLEG", "INBOUND_FEE", 700, 1, 9, direction, "AVAILABLE", "33.00"); + insert(jdbc, 7, "FIRSTLEG", "CONTAINER_FEE", 700, 1, 9, direction, "AVAILABLE", "44.00"); + insert(jdbc, 8, "FIRSTLEG", "INBOUND_FEE", 703, 1, 10, direction, "AVAILABLE", "55.00"); + jdbc.update("INSERT INTO fin_audit_log VALUES(1,9,'FIN_FEE_SOURCE',1,'FEE_SOURCE_REVIEW_APPROVE','审核员','原费用审核',NULL,NULL,'2026-10-21 10:01:30')"); + jdbc.update("INSERT INTO fin_audit_log VALUES(2,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_ADJUST','财务员','更正费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:03:00')"); + jdbc.update("INSERT INTO fin_audit_log VALUES(3,9,'FIN_FEE_SOURCE',3,'FEE_SOURCE_ADJUST','其他费用','不能合并',NULL,NULL,'2026-10-21 10:04:00')"); + jdbc.update("INSERT INTO fin_audit_log VALUES(4,10,'FIN_FEE_SOURCE',1,'FEE_SOURCE_ADJUST','其他客户','不能越界',NULL,NULL,'2026-10-21 10:04:00')"); + + var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), data)); + configuration.addMapper(FinanceFeeSourceReadMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) { + var repository = new FinanceFeeSourceReadRepository(session.getMapper(FinanceFeeSourceReadMapper.class), new ObjectMapper()); + var filters = repository.filters(9L, direction, null, "APPROVED", false, + LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 31), "操作费", 1, 20); + assertThat(repository.count(filters)).isEqualTo(5); + var rows = repository.list(filters); + assertThat(rows).extracting(row -> row.feeSourceId()).containsExactly(7L, 6L, 5L, 3L, 2L); + assertThat(rows.stream().map(row -> row.amount()).reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo("347.00"); + assertThat(rows).allSatisfy(row -> { + assertThat(row.direction()).isEqualTo(direction); + assertThat(row.customerId()).isEqualTo(9); + assertThat(row.supplierId()).isEqualTo(79); + assertThat(row.inboundCode()).isEqualTo("IN-9"); + assertThat(row.businessDate()).isEqualTo(LocalDate.of(2026, 10, 21)); + assertThat(row.originalAmount()).isEqualByComparingTo("200"); + assertThat(row.unitPrice()).isEqualByComparingTo("20"); + }); + filters.put("limit", 2); + filters.put("offset", 2); + assertThat(repository.count(filters)).isEqualTo(5); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(5L, 3L); + filters.put("limit", 20); filters.put("offset", 0); filters.put("status", "AVAILABLE"); + assertThat(repository.count(filters)).isEqualTo(4); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).doesNotContain(4L, 5L); + filters.put("status", "SUPERSEDED"); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(1L); + + var logs = repository.logs(9, 2); + assertThat(logs).hasSize(4); + assertThat(logs).extracting(log -> log.action()).containsExactly( + "FEE_SOURCE_ADJUST", "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_PUBLISHED"); + assertThat(logs.get(0).remark()).isEqualTo("更正费用;金额 100 → 120 CNY"); + assertThat(logs.get(1).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY"); + assertThat(logs).noneSatisfy(log -> assertThat(log.operatorName()).startsWith("其他")); + assertThat(repository.logs(9, 2)).isEqualTo(logs); + assertThat(repository.logs(9, 1)).hasSize(2); + assertThat(repository.logs(10, 2)).isEmpty(); + // 同毫秒必须先展示新版本;审计仍按 ID 倒序,不能反转金额差分或突破旧版本锚点。 + jdbc.update("UPDATE fin_fee_source SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)"); + jdbc.update("UPDATE fin_audit_log SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)"); + jdbc.update("INSERT INTO fin_audit_log VALUES(6,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_REVIEW_APPROVE','审核员','同毫秒后审计',NULL,NULL,'2026-10-21 10:03:00.123')"); + session.clearCache(); + var sameTimeLogs = repository.logs(9, 2); + assertThat(sameTimeLogs).extracting(log -> log.action()).containsExactly( + "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE", + "FEE_SOURCE_ADJUST", "FEE_SOURCE_REVIEW_APPROVE"); + assertThat(sameTimeLogs.get(0).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY"); + assertThat(sameTimeLogs.get(1).remark()).contains("来源版本 1;金额 100.000000 CNY"); + assertThat(sameTimeLogs.subList(2, 5)).extracting(log -> log.remark()).containsExactly( + "同毫秒后审计", "更正费用;金额 100 → 120 CNY", "原费用审核"); + assertThat(repository.logs(9, 2)).isEqualTo(sameTimeLogs); + assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly( + "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8); + jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)"); + filters.put("status", "BILLED"); filters.put("statementGenerated", true); + assertThat(repository.count(filters)).isEqualTo(1); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); + + // 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。 + insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00"); + jdbc.update("INSERT INTO fin_audit_log VALUES(5,9,'FIN_FEE_SOURCE',9,'FEE_SOURCE_ADJUST','财务员','更正人工费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:05:00')"); + assertThat(repository.logs(9, 9)).singleElement().satisfies(log -> { + assertThat(log.action()).isEqualTo("FEE_SOURCE_ADJUST"); + assertThat(log.remark()).isEqualTo("更正人工费用;金额 100 → 120 CNY"); + }); + } + } catch (java.sql.SQLException failure) { throw new AssertionError(failure); } + } + + /** 准备具有相同名称、不同稳定来源的隔离费用快照。 */ + private static void insert(JdbcTemplate jdbc, long id, String system, String type, long fee, int version, + long customer, String direction, String status, String amount) { + jdbc.update("INSERT INTO fin_fee_source(id,source_system,source_type,source_fee_id,source_fee_version,customer_id,direction,source_no,fee_item_name,amount,currency_code,review_status,status,source_snapshot,created_at) VALUES(?,?,?,?,?,?,?,'IN-9','操作费',?,'CNY','APPROVED',?,?,?)", + id, system, type, fee, version, customer, direction, new BigDecimal(amount), status, + "{\"businessDate\":\"2026-10-21\",\"supplierId\":79,\"inboundCode\":\"IN-9\",\"chargeableQuantity\":10,\"originalAmount\":200}", + java.sql.Timestamp.valueOf("2026-10-21 10:0" + version + ":00")); + } + + /** H2 仅补足 MySQL JSON 读取函数,实际筛选、关联和分页仍执行正式 SQL。 */ + public static String jsonExtract(String value, String path) throws Exception { + var node = new ObjectMapper().readTree(value).path(path.substring(2)); + return node.isMissingNode() || node.isNull() ? null : node.toString(); + } + + /** 对测试 JSON 标量解引号,保持 MySQL 空值语义。 */ + public static String jsonUnquote(String value) throws Exception { + return value == null ? null : new ObjectMapper().readTree(value).asText(); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 7b826cd3b..fa4cdff2d 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -9,10 +9,13 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.repository.FinanceFeeSourceReadRepository; import com.baiyue.finance.service.FinanceCustomerAccountService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.jdbc.core.JdbcTemplate; @@ -40,6 +43,9 @@ import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class FinancePhase1OperationBehaviorTest { @@ -81,18 +87,20 @@ class FinancePhase1OperationBehaviorTest { assertThat(response.page().total()).isEqualTo(1); } + /** 无效客户在进入持久化查询前拒绝。 */ @Test void ledgerRejectsInvalidCustomerBeforeAnyQuery() { - FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); } + /** 保留客户账户汇总的固定分页与金额口径。 */ @Test void accountListReturnsFixedPagedDto() { AccountJdbc jdbc = new AccountJdbc(); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); PageEnvelope result = service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE"); @@ -112,10 +120,11 @@ class FinancePhase1OperationBehaviorTest { assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); } + /** 所有派生流水查询保留目标客户范围。 */ @Test void ledgerQueriesEveryDerivedSourceWithinCustomerScope() { AccountJdbc jdbc = new AccountJdbc(); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); PageEnvelope result = service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); @@ -128,58 +137,24 @@ class FinancePhase1OperationBehaviorTest { .contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'")); } + /** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */ @Test - void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class))) - .thenReturn(1L); - when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); - ResultSet rs = mock(ResultSet.class); - when(rs.getLong("id")).thenReturn(88L); - when(rs.getLong("source_fee_id")).thenReturn(9001L); - when(rs.getInt("source_fee_version")).thenReturn(3); - when(rs.getLong("customer_id")).thenReturn(9L); - when(rs.getLong("order_id")).thenReturn(19L); - when(rs.getLong("inbound_id")).thenReturn(29L); - when(rs.getLong("container_id")).thenReturn(39L); - when(rs.getString("source_system")).thenReturn("FIRSTLEG"); - when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); - when(rs.getString("source_no")).thenReturn("IN-9"); - when(rs.getString("direction")).thenReturn("RECEIVABLE"); - when(rs.getString("fee_item_name")).thenReturn("入库操作费"); - when(rs.getString("currency_code")).thenReturn("CNY"); - when(rs.getString("review_status")).thenReturn("APPROVED"); - when(rs.getString("effective_status")).thenReturn("AVAILABLE"); - when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}"); - when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00")); - when(rs.getBoolean("statement_generated")).thenReturn(false); - return List.of(mapper.mapRow(rs, 0)); - }); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); - + void feeSourceListUsesFormalFieldsAndAllActiveFilters() { + var reads = mock(FinanceFeeSourceReadRepository.class); + var filters = java.util.Map.of("query", "current"); + when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false, + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters); + when(reads.list(filters)).thenReturn(List.of()); + when(reads.count(filters)).thenReturn(3L); + var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads); var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved", false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9"); - - assertThat(result.page()).satisfies(meta -> { - assertThat(meta.page()).isEqualTo(2); - assertThat(meta.pageSize()).isEqualTo(50); - assertThat(meta.total()).isEqualTo(1); - }); - assertThat(result.data()).singleElement().satisfies(source -> { - assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG"); - assertThat(source.sourceBusinessId()).isEqualTo("IN-9"); - assertThat(source.inboundCode()).isEqualTo("IN-9"); - assertThat(source.containerNo()).isEqualTo("CONT-9"); - assertThat(source.supplierId()).isEqualTo(79L); - assertThat(source.supplierName()).isEqualTo("供应商A"); - assertThat(source.reviewStatus()).isEqualTo("APPROVED"); - assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11)); - assertThat(source.statementGenerated()).isFalse(); - }); - verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class)); + assertThat(result.page().total()).isEqualTo(3); + assertThat(result.page().page()).isEqualTo(2); + verify(reads).list(filters); } + /** 流水返回业务单号和操作者快照。 */ @Test void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); @@ -197,7 +172,7 @@ class FinancePhase1OperationBehaviorTest { }); when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); PageEnvelope result = service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); @@ -211,18 +186,20 @@ class FinancePhase1OperationBehaviorTest { }); } + /** 保留费用审核状态读取。 */ @Test void approvedFeeCannotBeEditedAsDraft() throws Exception { JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED"); } + /** 费用详情保持原客户与来源版本。 */ @Test void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { FinancePhase1OperationService service = new FinancePhase1OperationService( - feeSourceJdbc("APPROVED", true, 2), objectMapper()); + feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); FeeSourceDetail detail = service.getFinanceFeeSource(88L); @@ -232,10 +209,11 @@ class FinancePhase1OperationBehaviorTest { assertThat(detail.reviewStatus()).isEqualTo("APPROVED"); } + /** 业务来源费用必须回到所属系统调整。 */ @Test void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception { JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); var request = new FinancePhase1Dtos.FeeSourceEditRequest( new BigDecimal("120.00"), "更正金额", 1); @@ -247,6 +225,7 @@ class FinancePhase1OperationBehaviorTest { (Object[]) any()); } + /** 头程作废等待权威来源事件,不能直接改财务副本。 */ @Test void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception { JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2); @@ -257,7 +236,7 @@ class FinancePhase1OperationBehaviorTest { .thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult( "INBOUND_FEE", 700L, 3, "VOIDED", false))); FinancePhase1OperationService service = new FinancePhase1OperationService( - jdbc, objectMapper(), firstleg, "internal-token"); + jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class)); FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance", "void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2)); @@ -278,6 +257,7 @@ class FinancePhase1OperationBehaviorTest { (Object[]) any()); } + /** 审核以当前操作版本更新并追加同一记录的审计。 */ @Test void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); @@ -289,7 +269,6 @@ class FinancePhase1OperationBehaviorTest { return List.of(mapper.mapRow(resultSet(review, false, version), 0)); }); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); - when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), @@ -301,7 +280,7 @@ class FinancePhase1OperationBehaviorTest { eq(71L), eq("操作人 71"), any(), any())).thenReturn(1); when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key", new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); @@ -311,6 +290,51 @@ class FinancePhase1OperationBehaviorTest { verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0)); } + /** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */ + @ParameterizedTest + @ValueSource(strings = {"RECEIVABLE", "PAYABLE"}) + void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception { + var jdbc = mock(JdbcTemplate.class); + var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class))); + var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE") + .put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9) + .put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2) + .put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false); + var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class); + var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120) + .put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class); + doReturn(before, after).when(service).getFinanceFeeSource(88L); + lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"))) + .thenReturn(1, 0); + var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2); + var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true); + assertThat(result.feeSourceId()).isEqualTo(88); + assertThat(result.sourceFeeId()).isEqualTo(700); + assertThat(result.direction()).isEqualTo(direction); + assertThat(result.amount()).isEqualByComparingTo("120"); + assertThat(result.operationVersion()).isEqualTo(3); + var hash = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), + eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture()); + lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once"))) + .thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L))); + assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after); + verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")); + verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L), + eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any()); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once", + new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求"); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本"); + doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class)) + .when(service).getFinanceFeeSource(88L); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据"); + } + + /** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */ private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { @@ -318,7 +342,6 @@ class FinancePhase1OperationBehaviorTest { return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0)); }); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); - when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); return jdbc; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java index 3fe11e0b5..5bbaf6269 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @@ -55,28 +56,34 @@ public interface LoadingCompletionCommandMapper extends BaseMapperCURRENT_TIMESTAMP(3) """) int renew(@Param("id") long id, @Param("worker") String worker, @Param("leaseUntil") LocalDateTime leaseUntil); + /** 完成落库必须仍持有有效租约,拒绝过期或已易主的旧执行者。 */ @Update(""" UPDATE fl_loading_completion_command SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3), lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL, failure_request_id=NULL,failure_trace_id=NULL WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker} + AND lease_until>CURRENT_TIMESTAMP(3) """) int complete(@Param("id") long id, @Param("worker") String worker); + /** 失败回执也受当前有效租约保护,不能覆盖新执行者的状态。 */ @Update(""" UPDATE fl_loading_completion_command SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3), lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code}, failure_request_id=#{requestId},failure_trace_id=#{traceId} WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker} + AND lease_until>CURRENT_TIMESTAMP(3) """) int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status, @Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage, @@ -97,13 +104,32 @@ public interface LoadingCompletionCommandMapper extends BaseMapperCURRENT_TIMESTAMP(3) + FOR UPDATE + """) + Long lockCheckpointLease(@Param("commandId") long commandId, @Param("worker") String worker); + + /** 在已锁定的命令行上重验有效租约;检查点只能前进,不能被迟到写入降级。 */ @Insert(""" INSERT INTO fl_loading_completion_checkpoint (command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage) - VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage}) - ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3) + SELECT id,#{tenantId},#{inboundId},#{sequence},#{stage} + FROM fl_loading_completion_command + WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker} + AND lease_until>CURRENT_TIMESTAMP(3) + ON DUPLICATE KEY UPDATE checkpoint_stage=CASE + WHEN checkpoint_stage='INBOUND_COMPLETED' OR #{stage}='INBOUND_COMPLETED' + THEN 'INBOUND_COMPLETED' + WHEN checkpoint_stage='SETTLED' OR #{stage}='SETTLED' THEN 'SETTLED' + ELSE 'READY' END,updated_at=CURRENT_TIMESTAMP(3) """) - int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId, + int checkpointCompleted(@Param("commandId") long commandId, @Param("worker") String worker, + @Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("sequence") int sequence, @Param("stage") String stage); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java index 7488f1163..5c25bd370 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java @@ -11,16 +11,27 @@ import java.util.stream.Collectors; @Service public class AppointmentForecastService { public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest, - AppointmentForecastMapper.Fact previous,String timeBasis) { } + AppointmentForecastMapper.Fact previous,String timeBasis, + java.time.LocalDateTime portArrivalAt) { } private final AppointmentForecastMapper mapper; - public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;} + private final com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources; + /** 读取船节点及日历权威到港来源,不从调用端接收推算日期。 */ + public AppointmentForecastService(AppointmentForecastMapper mapper, + com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources) { + this.mapper=mapper; this.sources=sources; + } + /** 返回明确来源柜的到港事实,保留未知日期。 */ public List batch(List ids) { if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0) || new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次"); var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId)); + var arrivals=sources.byIds(ids).stream().collect(Collectors.toMap( + com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource::containerBookingId, + java.util.function.Function.identity())); return ids.stream().map(id->{ var rows=facts.getOrDefault(id,List.of()); - return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED"); + return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED", + arrivals.containsKey(id)?arrivals.get(id).arrivalAt():null); }).toList(); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java index d2f23881e..379c5126b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java @@ -4,6 +4,7 @@ import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; @@ -12,6 +13,9 @@ import java.util.List; public class LoadingCompletionCommandRepository { @Resource private LoadingCompletionCommandMapper mapper; + /** 按事务提交后的命令标识读取唯一完成请求。 */ + public LoadingCompletionCommandEntity command(long id) { return mapper.selectById(id); } + public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); } public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); } public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); } @@ -28,7 +32,13 @@ public class LoadingCompletionCommandRepository { public String checkpoint(long commandId, long tenantId, long inboundId) { return mapper.checkpoint(commandId, tenantId, inboundId); } - public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) { - mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage); + /** 命令行锁覆盖租约检查与检查点写入,旧执行者不能在租约易主后覆盖新结果。 */ + @Transactional + public boolean checkpoint(long commandId, String worker, long tenantId, long inboundId, + int sequence, String stage) { + if (mapper.lockCheckpointLease(commandId, worker) == null) return false; + int written = mapper.checkpointCompleted(commandId, worker, tenantId, inboundId, sequence, stage); + // 同阶段重放可能被驱动计为零行;命令行仍被本事务锁住,重验租约区分幂等与过期拒绝。 + return written > 0 || mapper.lockCheckpointLease(commandId, worker) != null; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java index c03263fc5..0057602df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java @@ -9,37 +9,77 @@ import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.core.task.TaskExecutor; import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; import java.time.LocalDateTime; import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; -/** Lease-based recovery worker for PDA completion commands. */ +/** 事务提交后立即处理 PDA 完成命令,并按持久化租约定时恢复。 */ @Service public class LoadingCompletionWorker { private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class); private static final int MAX_ATTEMPTS = 5; - private final String workerId = "pda-completion-" + UUID.randomUUID(); @Resource private LoadingCompletionCommandRepository commands; @Resource private LoadingPickRepository picks; @Resource private LoadingPickService loadingPicks; + @Resource(name = "applicationTaskExecutor") private TaskExecutor completionExecutor; - @Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}") - public void dispatch() { - for (LoadingCompletionCommandEntity candidate : commands.due(10)) { - if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) { - continue; - } - try { - loadingPicks.processCompletion(candidate, workerId); - } catch (RuntimeException exception) { - handleFailure(candidate, exception); - } + /** 唤醒只发生在受理事务提交后,异步执行不延长 HTTP 受理请求。 */ + @TransactionalEventListener + public void accepted(LoadingPickService.CompletionAccepted accepted) { + try { + completionExecutor.execute(() -> dispatchAccepted(accepted)); + } catch (RejectedExecutionException exception) { + // 执行器繁忙或关闭时保留持久化命令,仍由定时扫描领取,不能影响已提交回执。 + log.warn("pda_completion_wakeup_deferred commandId={}", accepted.commandId(), exception); } } - private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) { + /** 唤醒与恢复共用原子领取;同一命令不会被两个执行入口重复结算。 */ + private void dispatchAccepted(LoadingPickService.CompletionAccepted accepted) { + try { + LoadingCompletionCommandEntity command = commands.command(accepted.commandId()); + if (command != null) process(command, "immediate", accepted.acceptedAtNanos()); + } catch (RuntimeException exception) { + log.warn("pda_completion_wakeup_failed commandId={}", accepted.commandId(), exception); + } + } + + /** 扫描到期或租约过期命令,覆盖唤醒丢失、进程重启及失败退避恢复。 */ + @Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}") + public void dispatch() { + for (LoadingCompletionCommandEntity candidate : commands.due(10)) { + process(candidate, "scheduled", System.nanoTime()); + } + } + + /** 保留租约、检查点和失败恢复语义,记录领取及实际处理耗时。 */ + private void process(LoadingCompletionCommandEntity command, String source, long queuedAt) { + // 每次领取使用独立所有者,避免旧执行线程在租约过期、同进程重新领取后继续写入。 + String leaseOwner = "pda-completion-" + UUID.randomUUID(); + if (!commands.claim(command.getId(), leaseOwner, LocalDateTime.now().plusMinutes(10))) return; + long started = System.nanoTime(); + boolean succeeded = false; + log.info("pda_completion_started commandId={} taskId={} source={} wakeupMs={}", + command.getId(), command.getPickTaskId(), source, (started - queuedAt) / 1_000_000); + try { + loadingPicks.processCompletion(command, leaseOwner); + succeeded = true; + } catch (RuntimeException exception) { + handleFailure(command, leaseOwner, exception); + } finally { + log.info("pda_completion_finished commandId={} taskId={} source={} durationMs={} succeeded={}", + command.getId(), command.getPickTaskId(), source, (System.nanoTime() - started) / 1_000_000, + succeeded); + } + } + + /** 确定性失败关闭任务,其余故障按原退避与最大次数恢复。 */ + private void handleFailure(LoadingCompletionCommandEntity command, String leaseOwner, RuntimeException exception) { Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException && exception.getCause() != null ? exception.getCause() : exception; String stage = exception instanceof LoadingPickService.CompletionProcessingException staged @@ -53,7 +93,7 @@ public class LoadingCompletionWorker { int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1; boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS; LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4))); - boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt, + boolean recorded = commands.fail(command.getId(), leaseOwner, terminal ? "FAILED" : "RETRYABLE", retryAt, stage, code, requestId, traceId); if (terminal && recorded) { picks.failCompletion(command.getPickTaskId()); @@ -63,6 +103,7 @@ public class LoadingCompletionWorker { command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause); } + /** 仅将已明确不可重试的本地业务门禁判为终态。 */ private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) { if (!(cause instanceof BizException business)) { return false; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index e123514c4..d40166d80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -56,6 +56,7 @@ import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -87,6 +88,8 @@ public class LoadingPickService { @Resource private LoadingCompletionCommandRepository completionCommands; @Resource + private ApplicationEventPublisher completionEvents; + @Resource private TransactionTemplate completionTransaction; @Resource private SmartLoadingRepository smartLoadingRepository; @@ -552,6 +555,7 @@ public class LoadingPickService { || !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) { throw conflict("装柜完成命令状态已变化,请刷新后重试"); } + completionEvents.publishEvent(new CompletionAccepted(replay.getId(), System.nanoTime())); } return view(requiredTask(taskId), null); } @@ -598,9 +602,14 @@ public class LoadingPickService { if (repository.acceptCompletion(taskId, task.getVersion()) != 1) { throw conflict("PDA任务版本已变化,请刷新后复核"); } + // 提交后立即唤醒仍经持久化租约领取;回滚不得触发结算,定时扫描继续负责故障恢复。 + completionEvents.publishEvent(new CompletionAccepted(command.getId(), System.nanoTime())); return view(requiredTask(taskId), null); } + /** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */ + public record CompletionAccepted(long commandId, long acceptedAtNanos) {} + private void requireCompletionContainerNumber(long taskId) { ContainerBookingEntity booking = repository.activeBookingByTask(taskId); if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { @@ -613,7 +622,7 @@ public class LoadingPickService { } } - /** Called only by the completion worker, after its durable lease has committed. */ + /** 仅在持久化租约领取成功后执行,远程结算不占用受理事务。 */ public void processCompletion(LoadingCompletionCommandEntity command, String worker) { long taskId = command.getPickTaskId(); inboundHoldGuard.requireTaskNotHeld(taskId); @@ -625,55 +634,55 @@ public class LoadingPickService { .toList(); if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单")); List taskGroups = coloadGroups(owners); - runCompletionStage("CABINET_EVIDENCE", () -> + runCompletionStage(taskId, "CABINET_EVIDENCE", () -> cabinetEvidenceService.requireComplete(task.getContainerBookingId())); - runCompletionStage("COMPLETED_EVENT_READY", () -> + runCompletionStage(taskId, "COMPLETED_EVENT_READY", () -> loadingListService.requireCompletedEventReady(task.getLoadingPlanId())); - // Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates - // before any warehouse resource or inbound status can be changed. This prevents a later customer gate from - // leaving earlier customers partially completed. + // 全柜所有客户先通过只读门禁,再开始资源结算,避免后续客户失败造成前序客户局部完成。 for (int index = 0; index < owners.size(); index++) { requireCompletionLease(command.getId(), worker); LoadingPickItemMapper.InboundOwner owner = owners.get(index); String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); if (!checkpointAtLeast(checkpoint, "SETTLED")) { - runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( + runCompletionStage(taskId, "OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( owner.tenantId(), owner.inboundId())); if (!checkpointAtLeast(checkpoint, "READY")) { - requireCompletionLease(command.getId(), worker); - completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), + recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "READY"); } } } - // Phase 2 is recoverable by per-owner checkpoints and stable idempotency keys. It starts only after the - // complete cabinet passed phase 1, while prior deployments' completed checkpoints remain safely resumable. + // 全柜门禁通过后,按客户检查点和稳定幂等键逐步结算,已完成的检查点可安全续做。 for (int index = 0; index < owners.size(); index++) { requireCompletionLease(command.getId(), worker); LoadingPickItemMapper.InboundOwner owner = owners.get(index); String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); if (!checkpointAtLeast(checkpoint, "SETTLED")) { - runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner( + runCompletionStage(taskId, "WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner( taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName())); - requireCompletionLease(command.getId(), worker); - completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED"); + recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "SETTLED"); } if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) { - runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( + runCompletionStage(taskId, "INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( owner.tenantId(), owner.inboundId(), command.getOperatorId(), command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark())); - requireCompletionLease(command.getId(), worker); - completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, + recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "INBOUND_COMPLETED"); } } - completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups)); + long transactionStarted = System.nanoTime(); + try { + completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups)); + } finally { + log.info("pda_completion_transaction taskId={} durationMs={}", taskId, + (System.nanoTime() - transactionStarted) / 1_000_000); + } } - /** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */ + /** 远程结算完成后,仅在短事务内收敛 Firstleg 本地完成事实。 */ private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker, LoadingPickTaskEntity task, List taskGroups) { long taskId = task.getId(); @@ -688,11 +697,11 @@ public class LoadingPickService { insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING", command.getOperatorId(), command.getOperatorName()); } - runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, + runCompletionStage(taskId, "PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, command.getOperatorId(), command.getOperatorName())); - runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); + runCompletionStage(taskId, "COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); for (GroupSnapshot group : taskGroups) { - runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(), + runCompletionStage(taskId, "COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(), task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|' + group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName())); } @@ -750,6 +759,14 @@ public class LoadingPickService { } } + /** 检查点失去当前有效租约时立即停止,不再推进后续客户或柜次。 */ + private void recordCompletionCheckpoint(long commandId, String worker, long tenantId, long inboundId, + int sequence, String stage) { + if (!completionCommands.checkpoint(commandId, worker, tenantId, inboundId, sequence, stage)) { + throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化")); + } + } + private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId, String requestHash, long operatorId) { if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId()) @@ -764,11 +781,18 @@ public class LoadingPickService { return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target); } - private static void runCompletionStage(String stage, Runnable action) { + /** 记录实际阶段耗时,区分业务处理与调度、前端刷新等待。 */ + private static void runCompletionStage(long taskId, String stage, Runnable action) { + long started = System.nanoTime(); + boolean succeeded = false; try { action.run(); + succeeded = true; } catch (RuntimeException exception) { throw new CompletionProcessingException(stage, exception); + } finally { + log.info("pda_completion_stage taskId={} stage={} durationMs={} succeeded={}", + taskId, stage, (System.nanoTime() - started) / 1_000_000, succeeded); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java index 00c852160..85317d783 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java @@ -9,6 +9,25 @@ import java.util.List; @Mapper public interface ContainerCalendarSourceMapper { + /** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */ + @Select(""" + + """) + List byIds(@Param("ids") List ids); + /** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */ @Select(""" SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java index 9e6588345..70693fd27 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java @@ -11,6 +11,8 @@ public class ContainerCalendarSourceRepository { private final ContainerCalendarSourceMapper mapper; /** 使用本服务 Mapper 读取日历所需柜级来源。 */ public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; } + /** 内部批量读取明确柜次,日期口径与平台日历一致。 */ + public List byIds(List ids) { return mapper.byIds(ids); } /** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */ public List sources(LocalDate from, LocalDate to) { return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(), diff --git a/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml b/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml index 7f0304f16..5ce9bd548 100644 --- a/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml +++ b/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml @@ -34,6 +34,7 @@ paths: properties: containerBookingId: {type: string} timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]} + portArrivalAt: {type: string, format: date-time, nullable: true, description: 与预约日历相同的权威到港当地时间,最新有效实际到港优先,其次柜资料实际到港及预计到港;未知保持空} latest: {$ref: '#/components/schemas/Fact'} previous: {$ref: '#/components/schemas/Fact'} '401': {description: 内部身份缺失、错误或服务端未配置} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java index f4992b9ca..477181eee 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java @@ -9,8 +9,20 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class AppointmentForecastServiceTest { + /** 无船期预计观察时仍返回日历所用的柜级到港事实,供海外统一推算日期。 */ + @Test void returnsTheCalendarArrivalEvenWhenLatestForecastIsAbsent() { + var mapper=mock(AppointmentForecastMapper.class); + var sources=mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class); + var arrival=LocalDateTime.of(2026,10,30,23,30); + when(sources.byIds(List.of(1L))).thenReturn(List.of( + new com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource(1L,"D0001","CONT1", + null,null,null,null,arrival,"ACTUAL"))); + var result=new AppointmentForecastService(mapper,sources).batch(List.of(1L)); + assertThat(result.get(0).latest()).isNull(); + assertThat(result.get(0).portArrivalAt()).isEqualTo(arrival); + } @Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() { - var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper); + var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class)); var observed=LocalDateTime.of(2026,10,10,12,0); var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null); var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null); @@ -21,7 +33,7 @@ class AppointmentForecastServiceTest { verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper); } @Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() { - var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper); + var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class)); for(var ids:List.of(List.of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L))) assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionCheckpointLeaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionCheckpointLeaseTest.java new file mode 100644 index 000000000..3f45f9909 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionCheckpointLeaseTest.java @@ -0,0 +1,190 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +/** 用真实 Mapper SQL、事务代理和独立连接验证完成检查点的租约隔离。 */ +class LoadingCompletionCheckpointLeaseTest { + private JdbcDataSource dataSource; + private JdbcTemplate jdbc; + private LoadingCompletionCommandMapper mapper; + private LoadingCompletionCommandRepository commands; + + /** 每个用例使用独立内存数据库,不访问共享环境。 */ + @BeforeEach + void setup() throws Exception { + dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:completion-lease-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=1000"); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute(""" + CREATE TABLE fl_loading_completion_command( + id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP, + attempt_count INT,next_retry_at TIMESTAMP,updated_at TIMESTAMP,completed_at TIMESTAMP, + failure_stage VARCHAR(40),failure_code VARCHAR(32),failure_request_id VARCHAR(64),failure_trace_id VARCHAR(64)) + """); + jdbc.execute(""" + CREATE TABLE fl_loading_completion_checkpoint( + command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24), + updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id)) + """); + jdbc.update(""" + INSERT INTO fl_loading_completion_command(id,command_status,lease_owner,lease_until,attempt_count,next_retry_at) + VALUES(900,'PROCESSING','old-owner',DATEADD('HOUR',1,CURRENT_TIMESTAMP),1,CURRENT_TIMESTAMP) + """); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(LoadingCompletionCommandMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setConfiguration(configuration); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LoadingCompletionCommandMapper.class); + commands = repository(mapper); + } + + /** 生产 Repository 的注解事务必须真实生效,覆盖锁定与写入两条 SQL。 */ + private LoadingCompletionCommandRepository repository(LoadingCompletionCommandMapper targetMapper) { + var target = new LoadingCompletionCommandRepository(); + ReflectionTestUtils.setField(target, "mapper", targetMapper); + var proxy = new ProxyFactory(target); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource), + new AnnotationTransactionAttributeSource())); + return (LoadingCompletionCommandRepository) proxy.getProxy(); + } + + /** 模拟原执行者续租后暂停,租约过期并被新执行者领取推进,再恢复旧线程。 */ + @Test + void pausedOldOwnerCannotDowngradeTheNewOwnersCompletedCheckpoint() throws Exception { + var renewed = new CountDownLatch(1); + var resumeOldOwner = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try { + var oldWrite = executor.submit(() -> { + assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isTrue(); + renewed.countDown(); + assertThat(resumeOldOwner.await(10, TimeUnit.SECONDS)).isTrue(); + return commands.checkpoint(900, "old-owner", 101, 50, 1, "READY"); + }); + assertThat(renewed.await(10, TimeUnit.SECONDS)).isTrue(); + jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900"); + assertThat(commands.claim(900, "new-owner", LocalDateTime.now().plusMinutes(10))).isTrue(); + assertThat(commands.checkpoint(900, "new-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue(); + resumeOldOwner.countDown(); + assertThat(oldWrite.get(10, TimeUnit.SECONDS)).isFalse(); + assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "SETTLED")).isZero(); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); + assertThat(jdbc.queryForObject("SELECT lease_owner FROM fl_loading_completion_command WHERE id=900", String.class)) + .isEqualTo("new-owner"); + } finally { + resumeOldOwner.countDown(); + executor.shutdownNow(); + } + } + + /** 锁定后的实际写入前,独立数据库连接仍不能取得同一命令行的锁。 */ + @Test + void commandRowRemainsLockedAcrossTheLeaseCheckAndCheckpointInsert() { + LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper)); + doAnswer(call -> { + assertThatThrownBy(() -> { + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement("SELECT id FROM fl_loading_completion_command WHERE id=900 FOR UPDATE NOWAIT")) { + statement.executeQuery().close(); + } + }).isInstanceOf(SQLException.class) + .satisfies(error -> assertThat(((SQLException) error).getErrorCode()).isEqualTo(50200)); + return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5)); + }).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); + assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue(); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY"); + } + + /** 初次锁定检查后若租约失效,实际 INSERT 也必须拒绝,不能仅依赖前一次检查。 */ + @Test + void checkpointInsertRechecksLeaseValidityAfterTheInitialLock() { + LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper)); + doAnswer(call -> { + jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900"); + return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5)); + }).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); + assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse(); + assertThat(commands.checkpoint(900, 101, 50)).isNull(); + } + + /** 驱动将已持久化的幂等重放计为零行时,仍须以锁内当前有效租约确认。 */ + @Test + void zeroAffectedRowsCanConfirmAnIdempotentCheckpointUnderTheSameValidLease() { + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue(); + LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper)); + doAnswer(call -> { + mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5)); + return 0; + }).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); + assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue(); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY"); + } + + /** 已过期但尚未被重新领取的所有者也不能写检查点、续租或写命令终态。 */ + @Test + void expiredLeaseCannotWriteCheckpointRenewFailOrComplete() { + jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900"); + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse(); + assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero(); + assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isFalse(); + assertThat(commands.fail(900, "old-owner", "FAILED", LocalDateTime.now(), "LEASE_LOST", "A0409", null, null)).isFalse(); + assertThat(commands.complete(900, "old-owner")).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_completion_checkpoint", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT command_status FROM fl_loading_completion_command WHERE id=900", String.class)) + .isEqualTo("PROCESSING"); + } + + /** 同一当前所有者的重复或迟到阶段也只能保持或推进,不能降低完成检查点。 */ + @Test + void validOwnerCheckpointStagesAreMonotonic() { + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED")).isTrue(); + commands.checkpoint(900, "old-owner", 101, 50, 1, "READY"); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("SETTLED"); + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue(); + commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED"); + commands.checkpoint(900, "old-owner", 101, 50, 1, "READY"); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); + } + + /** 完成或失败后的命令不允许追加检查点,即使所有者与租期字段仍有旧值。 */ + @Test + void terminalCommandCannotAcceptAnotherCheckpoint() { + jdbc.update("UPDATE fl_loading_completion_command SET command_status='COMPLETED' WHERE id=900"); + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse(); + assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java index 072f61b37..06278d63b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java @@ -31,6 +31,8 @@ class LoadingCompletionOwnerPersistenceTest { jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,deleted INT,package_count INT)"); jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)"); jdbc.execute("CREATE TABLE fl_loading_completion_checkpoint(command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))"); + jdbc.execute("CREATE TABLE fl_loading_completion_command(id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP)"); + jdbc.update("INSERT INTO fl_loading_completion_command VALUES(900,'PROCESSING','owner-test',DATEADD('HOUR',1,CURRENT_TIMESTAMP))"); jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)"); row(1, 101, 50, 3, "PARTIAL", 2, 10, 0); row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history @@ -76,11 +78,11 @@ class LoadingCompletionOwnerPersistenceTest { @Test void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() { - checkpoints.checkpointCompleted(900, 101, 50, 1, "READY"); - checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED"); - checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED"); - checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED"); - checkpoints.checkpointCompleted(900, 303, 70, 3, "READY"); + checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "READY"); + checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "SETTLED"); + checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, "owner-test", 202, 60, 2, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, "owner-test", 303, 70, 3, "READY"); assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED"); assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java index 264f0d2c2..6080d0cf1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java @@ -8,22 +8,124 @@ import com.baiyue.firstleg.repository.LoadingPickRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import org.mockito.ArgumentCaptor; import java.time.LocalDateTime; import java.util.List; +import java.util.concurrent.RejectedExecutionException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(LoadingCompletionWorker.class) +@SpringJUnitConfig({LoadingCompletionWorker.class, LoadingCompletionWorkerTest.TransactionEvents.class}) class LoadingCompletionWorkerTest { + /** 与生产事务装配一致,注册事务事件工厂,避免普通事件工厂立即调用监听器。 */ + @Configuration + @EnableTransactionManagement + static class TransactionEvents {} + @MockBean private LoadingCompletionCommandRepository commands; @MockBean private LoadingPickRepository picks; @MockBean private LoadingPickService loadingPicks; + @MockBean(name = "applicationTaskExecutor") private TaskExecutor completionExecutor; @jakarta.annotation.Resource private LoadingCompletionWorker worker; + @jakarta.annotation.Resource private ApplicationEventPublisher events; + + /** 真实事务同步回调必须在提交后才将完成工作交给执行器。 */ + @Test + void committedAcceptanceStartsImmediatelyThroughTheDurableLease() { + LoadingCompletionCommandEntity command = command(0); + when(commands.command(900L)).thenReturn(command); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + transactions().executeWithoutResult(status -> { + events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + verify(completionExecutor, never()).execute(any()); + verify(loadingPicks, never()).processCompletion(any(), any()); + }); + + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(completionExecutor).execute(work.capture()); + verify(loadingPicks, never()).processCompletion(any(), any()); + work.getValue().run(); + + verify(commands).claim(eq(900L), any(String.class), any(LocalDateTime.class)); + verify(loadingPicks).processCompletion(eq(command), any(String.class)); + } + + /** 回滚后的命令没有权威落库事实,不允许唤醒或执行结算。 */ + @Test + void rolledBackAcceptanceNeverSchedulesCompletion() { + transactions().executeWithoutResult(status -> { + events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + status.setRollbackOnly(); + }); + verify(completionExecutor, never()).execute(any()); + verify(commands, never()).command(900L); + } + + /** 即时唤醒不能绕过定时执行者已取得的租约。 */ + @Test + void immediateWakeupDoesNotProcessAnAlreadyLeasedCommand() { + when(commands.command(900L)).thenReturn(command(0)); + worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(completionExecutor).execute(work.capture()); + work.getValue().run(); + verify(loadingPicks, never()).processCompletion(any(), any()); + } + + /** 执行器拒绝时受理不报错,定时扫描仍可从原命令恢复。 */ + @Test + void rejectedWakeupLeavesScheduledRecoveryAvailable() { + doThrow(new RejectedExecutionException("busy")).when(completionExecutor).execute(any()); + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + worker.dispatch(); + verify(loadingPicks).processCompletion(eq(command), any(String.class)); + } + + /** 同进程重领已到期命令也必须更换所有者,旧线程不能续租或落库。 */ + @Test + void everyClaimUsesAnIndependentLeaseOwner() { + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + worker.dispatch(); + worker.dispatch(); + ArgumentCaptor owners = ArgumentCaptor.forClass(String.class); + verify(loadingPicks, times(2)).processCompletion(eq(command), owners.capture()); + assertThat(owners.getAllValues()).doesNotHaveDuplicates(); + } + + /** 隔离数据库依赖,仅用 Spring 的真实事务同步机制核对提交、回滚边界。 */ + private static TransactionTemplate transactions() { + return new TransactionTemplate(new AbstractPlatformTransactionManager() { + /** 为每次测试提供独立事务对象。 */ + @Override protected Object doGetTransaction() { return new Object(); } + /** 测试事务不访问数据库。 */ + @Override protected void doBegin(Object transaction, TransactionDefinition definition) {} + /** 让 Spring 正常触发提交同步回调。 */ + @Override protected void doCommit(DefaultTransactionStatus status) {} + /** 让 Spring 正常触发回滚同步回调。 */ + @Override protected void doRollback(DefaultTransactionStatus status) {} + }); + } @Test void transientFailureIsReleasedForBackoffRetry() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 04795248e..982738c4c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -50,6 +50,9 @@ import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionTemplate; @@ -77,7 +80,9 @@ import static org.mockito.Mockito.clearInvocations; import static org.assertj.core.api.Assertions.assertThat; @SpringJUnitConfig(LoadingPickService.class) +@RecordApplicationEvents class LoadingPickServiceTest { + @Autowired private ApplicationEvents completionEvents; @MockBean private LoadingPickRepository repository; @MockBean @@ -154,6 +159,8 @@ class LoadingPickServiceTest { when(repository.activeBookingByTask(20L)).thenReturn(booking); when(completionCommands.insert(any())).thenReturn(1); when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true); + when(completionCommands.checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString())) + .thenReturn(true); when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1); when(repository.inboundOwners(anyLong())).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); @@ -931,6 +938,7 @@ class LoadingPickServiceTest { assertThat(replay.taskStatus()).isEqualTo("COMPLETING"); verify(repository, never()).taskForUpdate(20L); verify(completionCommands, times(1)).insert(any()); + assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class).count()).isEqualTo(1); verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); } @@ -997,6 +1005,8 @@ class LoadingPickServiceTest { assertThat(result.workflowStatus()).isEqualTo("PROCESSING"); verify(completionCommands).resume(902L); + assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class) + .map(LoadingPickService.CompletionAccepted::commandId)).containsExactly(902L); verify(repository).resumeCompletion(20L); verify(completionCommands, never()).insert(any()); } @@ -1089,7 +1099,7 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); verify(completionCommands, never()).checkpoint(eq(931L), eq(303L), eq(70L)); - verify(completionCommands, never()).checkpoint(eq(931L), anyLong(), anyLong(), anyInt(), anyString()); + verify(completionCommands, never()).checkpoint(eq(931L), anyString(), anyLong(), anyLong(), anyInt(), anyString()); verify(loadingListService).publishCompletedEvent(10L); verify(completionCommands).complete(931L, "worker-completed-checkpoints"); } @@ -1109,6 +1119,31 @@ class LoadingPickServiceTest { verify(loadingListService, never()).publishCompletedEvent(anyLong()); } + /** 任一检查点被租约拒绝后都不得继续下一业务步骤或最终完成柜次。 */ + @ParameterizedTest + @ValueSource(strings = {"READY", "SETTLED", "INBOUND_COMPLETED"}) + void lostLeaseAtCheckpointStopsFurtherCompletion(String rejectedStage) { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(completionCommands.checkpoint(eq(940L), eq("old-owner"), eq(101L), eq(50L), eq(1), eq(rejectedStage))) + .thenReturn(false); + + assertThatThrownBy(() -> service.processCompletion(completionCommand(940L, "lost-checkpoint"), "old-owner")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage()) + .isEqualTo("LEASE_LOST")); + if ("READY".equals(rejectedStage)) { + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + } + if (!"INBOUND_COMPLETED".equals(rejectedStage)) { + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), + anyString(), anyString(), anyString()); + } + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + verify(completionCommands, never()).complete(anyLong(), anyString()); + } + @Test void anEntirelySkippedTaskCannotCreateACompletionCommand() { when(repository.taskForUpdate(20L)).thenReturn(task()); @@ -1271,8 +1306,8 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); - verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY"); - verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(), + verify(completionCommands).checkpoint(904L, "worker-failure", 101L, 50L, 1, "READY"); + verify(completionCommands, never()).checkpoint(eq(904L), anyString(), anyLong(), anyLong(), anyInt(), eq("SETTLED")); verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); @@ -1300,7 +1335,7 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); - verify(completionCommands, never()).checkpoint(anyLong(), anyLong(), anyLong(), anyInt(), anyString()); + verify(completionCommands, never()).checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).publishCompletedEvent(anyLong()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java index 6b5a89190..7649869ee 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java @@ -46,6 +46,8 @@ class ContainerCalendarSourceMapperTest { var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1)); var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L)); assertEquals(2, calendar.size()); + var explicit = session.getMapper(ContainerCalendarSourceMapper.class).byIds(java.util.List.of(1L,2L)); + assertEquals(calendar,explicit); assertEquals(2, workbench.size()); for (int index = 0; index < calendar.size(); index++) { var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00"); diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index c2221b6fe..18aae008f 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -22,10 +22,10 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | -| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | +| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜,并由 `ContainerWarehouseArrivalService` 统一解析:人工维护日期或已有计划冻结日期优先,未维护未建计划时读取 Firstleg 权威到港当地日期加两个自然日;缺失或同柜不一致时阻断,清关提柜、日历和人工/自动计划统一使用 `plannedDate`,默认日期不补零点,首个计划创建后按已有计划日期冻结;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | -| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | +| 尾程单号生成 | `service/LastlegOrderNoGenerator.java`、`service/LastlegDocumentNumberGenerator.java`;头程来源使用14位 `WC + yyyyMMdd + 四位流水`,建单经 `service/LoadingCompletedConsumer.java` 在来源柜锁内复用已有号,正式格式见工作区 `docs/业务单据编号契约.md` | | 订单查询与更新 | `service/LastlegOrderService.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | @@ -112,7 +112,7 @@ ## 事件建单规则 - 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 -- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- 同一冻结来源柜 `sourceContainerBookingId` 共用一个14位 `WC + yyyyMMdd + 四位流水` 尾程单号;首个交接事件在来源柜行锁内取号,后续客户或跨日交接复用该柜已保存的编号,事件重放不取新号。不同来源柜复用真实集装箱号时仍分别编号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。真实 `containerNo` 继续保留作实箱快照,`cabinetNo` 仅为柜次展示,不替代稳定来源柜身份。 - `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 - 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java index 75727828e..ff9c71e3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java @@ -11,7 +11,7 @@ public interface AppointmentForecastClient { record Request(List containerBookingIds) { } record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference, LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { } - record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis) { } + record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis,LocalDateTime portArrivalAt) { } @PostMapping("/internal/firstleg/appointment-forecasts/batch") ApiResponse> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java index b3666a448..0e410f503 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java @@ -12,24 +12,31 @@ public interface WarehouseCalendarMapper { @Select(""" ") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/ContainerWarehouseArrivalRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/ContainerWarehouseArrivalRepository.java new file mode 100644 index 000000000..d6b76cc0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/ContainerWarehouseArrivalRepository.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 柜级人工到仓与已有入库计划日期的唯一数据库读取入口。 */ +@Repository +public class ContainerWarehouseArrivalRepository { + private final LastlegOrderMapper mapper; + /** 复用本库柜级事实查询。 */ + public ContainerWarehouseArrivalRepository(LastlegOrderMapper mapper) { this.mapper=mapper; } + /** 保留同柜版本及计划数量,不把缺失时间转换成零点。 */ + public List find(List ids) { return mapper.findContainerArrivalTimes(ids); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/LastlegOrderNumberRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/LastlegOrderNumberRepository.java new file mode 100644 index 000000000..1c75aee56 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/LastlegOrderNumberRepository.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 读取来源柜已经分配的业务尾程号,不按客户拆分柜级身份。 */ +@Repository +public class LastlegOrderNumberRepository { + private final LastlegOrderMapper mapper; + + /** 复用所属服务订单 Mapper。 */ + public LastlegOrderNumberRepository(LastlegOrderMapper mapper) { this.mapper = mapper; } + + /** 调用方已持有来源柜门禁行锁,同柜的后续客户事件沿用首个尾程号。 */ + public String findContainerOrderNo(String bookingId) { + List values = mapper.containerOrderNumbers(bookingId); + if (values.size() > 1) throw new IllegalStateException("同一来源柜存在多个尾程业务编号"); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalService.java new file mode 100644 index 000000000..ce79135e9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalService.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.util.List; +import java.util.HashMap; +import java.util.Map; + +/** 清关列表、预约日历与入库计划共用的到仓日期解析。 */ +@Service +public class ContainerWarehouseArrivalService { + private final ContainerWarehouseArrivalRepository repository; + private final AppointmentForecastClient forecasts; + private final String token; + + /** 到港事实只从 Firstleg 权威来源读取,人工与计划日期只访问本库。 */ + public ContainerWarehouseArrivalService(ContainerWarehouseArrivalRepository repository, + AppointmentForecastClient forecasts,@Value("${baiyue.security.internal-service-token:}") String token) { + this.repository=repository; this.forecasts=forecasts; this.token=token; + } + + /** 人工时间及计划冻结日期优先,其余按最新到港当地日期加两个自然日。 */ + public List resolve(List ids) { + if(ids.isEmpty()) return List.of(); + var rows=repository.find(ids); + var pending=rows.stream().filter(row->row.version()==0 && row.planCount()==0 + && row.estimatedWarehouseArrivalAt()==null).map(row->Long.valueOf(row.sourceContainerBookingId())).toList(); + Map defaults=new HashMap<>(); + for(int start=0;start(); + for(var fact:response.data()) { + if(fact==null || !batch.contains(fact.containerBookingId()) || !seen.add(fact.containerBookingId())) throw unavailable(); + if(fact.portArrivalAt()!=null) defaults.put(fact.containerBookingId(),fact.portArrivalAt().toLocalDate().plusDays(2)); + } + } + return rows.stream().map(row->{ + boolean automatic=row.version()==0 && row.planCount()==0 && row.estimatedWarehouseArrivalAt()==null; + LocalDate date=row.estimatedWarehouseArrivalAt()!=null?row.estimatedWarehouseArrivalAt().toLocalDate() + :automatic?defaults.get(Long.valueOf(row.sourceContainerBookingId())):row.plannedDate(); + return new ContainerArrivalTimeView(row.sourceContainerBookingId(),row.estimatedWarehouseArrivalAt(), + row.actualWarehouseArrivalAt(),row.version(),row.planCount(),date,automatic && date!=null); + }).toList(); + } + + /** 生成计划沿用同一解析结果,缺失或不一致时阻断,不改写来源事实。 */ + public LocalDate requiredDate(String id) { + var rows=resolve(List.of(id)); + if(rows.size()!=1 || rows.get(0).version()<0 || rows.get(0).plannedDate()==null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源柜预计到仓日期缺失或不一致,请先核对柜级时间"); + return rows.get(0).plannedDate(); + } + + /** 来源失败必须明确提示,不能用本地旧快照伪造默认日期。 */ + private ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到港日期暂不可读取,请稍后重试"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java index 95a9a594f..9d3bb752e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java @@ -10,7 +10,7 @@ import java.util.Set; /** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ @Component public class LastlegDocumentNumberGenerator { - private static final Set PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR"); + private static final Set PREFIXES = Set.of("WC", "IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR"); private final DocumentSequenceRepository sequences; private final Clock clock; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index 3ba5d9878..6eaa838ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -1,14 +1,9 @@ package com.baiyue.lastleg.service; import org.springframework.stereotype.Component; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.Locale; -/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */ +/** 海外单据类型映射;业务单号统一使用本服务日流水。 */ @Component public class LastlegOrderNoGenerator { private final LastlegDocumentNumberGenerator documents; @@ -21,6 +16,7 @@ public class LastlegOrderNoGenerator { /** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */ public String next(String businessType) { String prefix = switch (businessType) { + case "LASTLEG" -> "WC"; case "ONE_CLICK_DROPSHIP" -> "DFCK"; case "OUTBOUND" -> "CK"; case "RELABEL_OUTBOUND" -> "HBCK"; @@ -45,16 +41,4 @@ public class LastlegOrderNoGenerator { return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT); } - /** 独立尾程编号尚未确认,头程装柜来源继续使用原有分组编号。 */ - public String forContainerNo(String containerNo) { - if (containerNo == null || containerNo.isBlank()) throw new IllegalArgumentException("containerNo is required"); - try { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(containerNo.trim().getBytes(StandardCharsets.UTF_8)); - long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong(); - return "FL-WH-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" - + String.format(Locale.ROOT, "%06d", Math.floorMod(suffix, 1_000_000)); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 is unavailable", exception); - } - } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 6e556f464..9feafcbce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -83,6 +83,7 @@ public class LastlegOrderService { @Resource private InventoryCommandService inventory; @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; + @Resource private ContainerWarehouseArrivalService warehouseArrival; @Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ReturnOrderReferenceResolver returnOrderReferences; @@ -142,6 +143,7 @@ public class LastlegOrderService { orderMapper.findContainerProgressItems(bookingId)); } + /** 清关列表读取统一解析的柜级日期,默认推算不写入订单。 */ @Transactional(readOnly = true) public List containerArrivalTimes( LastlegOrderRequests.ContainerArrivalTimeQuery request) { @@ -150,7 +152,7 @@ public class LastlegOrderService { if (bookingIds.size() != request.sourceContainerBookingIds().size()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复"); } - return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds)); + return warehouseArrival.resolve(List.copyOf(bookingIds)); } @Transactional @@ -194,8 +196,9 @@ public class LastlegOrderService { return requireContainerArrivalTime(bookingId); } + /** 保存后沿同一读取入口返回人工日期、版本和计划冻结事实。 */ private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) { - List values = orderMapper.findContainerArrivalTimes(List.of(bookingId)); + List values = warehouseArrival.resolve(List.of(bookingId)); if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); if (values.get(0).version() < 0) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 1b5471092..773fdf3c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -44,12 +44,14 @@ public class LoadingCompletedConsumer { @Resource private InboundPlanCartonMapper cartonMapper; @Resource private ObjectMapper objectMapper; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private com.baiyue.lastleg.repository.LastlegOrderNumberRepository orderNumbers; @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; @Resource private ContainerShippingGateMapper shippingGates; @Resource private LastlegSourcePalletMapper sourcePallets; @Resource private ContainerLoadingEvidenceMapper loadingEvidence; + /** 校验交接身份并在柜级锁内为各客户的冻结入仓单建立同柜尾程记录。 */ @Transactional(isolation = Isolation.READ_COMMITTED) public DeliveryAck consume(EventEnvelope envelope) { envelope.requireValid(); @@ -94,7 +96,9 @@ public class LoadingCompletedConsumer { // Seeding the shipping version from the loading-plan version makes a valid first node // (aggregateVersion=0) look stale whenever the loading plan has a higher version. JsonNode rawOrders = envelope.payload().path("orders"); - String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + // 来源柜行锁覆盖跨客户交接事件,首票取号,后续票沿用同柜已落库编号。 + String orderNo = orderNumbers.findContainerOrderNo(sourceContainerBookingId); + if (orderNo == null) orderNo = orderNoGenerator.next("LASTLEG"); for (int index = 0; index < payload.orders().size(); index++) { var source = payload.orders().get(index); JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); diff --git a/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml index 02ad3504d..5d55a1c11 100644 --- a/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml @@ -8,8 +8,8 @@ paths: description: >- 仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。 - 同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0或已经建计划时才有值。 - 未人工维护、未建计划的日历默认日期由到港来源加2个自然日;此只读接口不写入库计划、不补造时刻。 + 同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0时才有值;仅日期的冻结计划通过plannedDate返回。 + plannedDate/isDefault由与清关提柜和入库计划共用的服务端解析器返回,默认按权威到港当地日期加2个自然日;此只读接口不写入库计划、不补造时刻。 actualAt只读PDA首次收货时间,不替换计划日期。 具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。 保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。 @@ -41,10 +41,13 @@ paths: type: object properties: sourceContainerBookingId: {type: string} + cabinetNo: {type: string, nullable: true, description: 来源交接冻结的柜次号,作为日历主标识} containerNo: {type: string, nullable: true} warehouseName: {type: string, nullable: true} warehouseCode: {type: string, nullable: true} plannedAt: {type: string, format: date-time, nullable: true} + plannedDate: {type: string, format: date, nullable: true, description: 人工日期、已生成计划冻结日期或权威默认日期} + isDefault: {type: boolean, description: 仅当前未维护未建计划且有默认日期时为true} actualAt: {type: string, format: date-time, nullable: true} version: {type: integer, minimum: -1} planCount: {type: integer, minimum: 0} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index befd48f10..ed9a43846 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3582,7 +3582,9 @@ components: required: [sourceContainerBookingId, version, planCount] properties: sourceContainerBookingId: { type: string } - estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 } + estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护的整柜时间;默认日期及只有日期的冻结计划不补造时间 } + plannedDate: { type: string, format: date, nullable: true, description: 与日历和入库计划共用的服务端日期,人工或冻结日期优先,否则按权威到港当地日期加两个自然日 } + isDefault: { type: boolean, description: 未人工维护未建计划时使用权威到港推算日期 } actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 } version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 } planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 } @@ -4022,6 +4024,11 @@ components: remark: type: string nullable: true + maintenance: + allOf: + - $ref: '#/components/schemas/DispatchMaintenance' + nullable: true + description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。 required: - publishLooseboardDemand - recordVersion @@ -4558,7 +4565,7 @@ components: items: { $ref: '#/components/schemas/LastlegOrderManualNode' } ManualInboundPlanRequest: type: object - description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。 + description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由共用服务端解析器返回 plannedDate:人工时间或已有计划冻结日期优先,其余按 Firstleg 权威到港当地日期加两个自然日;同柜所有计划保持一致,不接受人工提交,不补造默认时间。 required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent] properties: customerId: { type: string, pattern: '^[1-9][0-9]*$' } @@ -5390,6 +5397,43 @@ components: - name - phone - evidence + DispatchMaintenance: + type: object + x-java-record: DispatchContracts.Maintenance + description: 保存于排车resourcesJson.maintenance的唯一正式资料结构,不新增费用金额或审核门禁。 + properties: + plannedDeliveryDate: + type: string + format: date + nullable: true + deliveryMethod: + type: string + enum: [FBA自提, 客户自提, 卡派打托, 卡派地板, 快递, 外配] + nullable: true + pickupMethod: + type: string + enum: [一提一卸, 一提多卸] + nullable: true + loadType: + type: string + enum: [托盘, 地板, 混装] + nullable: true + fleetEmail: + type: string + maxLength: 254 + nullable: true + vehicleType: + type: string + enum: [厢式货车, 平板车, 集装箱卡车] + nullable: true + appointmentAccount: + type: string + maxLength: 128 + nullable: true + appointmentNo: + type: string + maxLength: 128 + nullable: true DispatchResources: type: object x-java-record: DispatchContracts.Resources @@ -5402,6 +5446,11 @@ components: allOf: - $ref: '#/components/schemas/DispatchDriverSnapshot' nullable: true + maintenance: + allOf: + - $ref: '#/components/schemas/DispatchMaintenance' + nullable: true + description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。 required: - vehicle - driver @@ -6356,6 +6405,11 @@ components: allOf: - $ref: '#/components/schemas/DispatchDriverSnapshot' nullable: true + maintenance: + allOf: + - $ref: '#/components/schemas/DispatchMaintenance' + nullable: true + description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。 required: - vehicle - driver diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java index 17ae1e105..b3e0f1487 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java @@ -26,7 +26,7 @@ class AppointmentImpactPersistenceTest { return new AppointmentForecastClient.Fact(1L,revision,"CARRIER","REF",LocalDateTime.of(2026,10,10,12,0).plusMinutes(revision),LocalDateTime.of(2026,10,day,9,0),null); } static void observe(AnnotationConfigApplicationContext c,long revision,int day) { - when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED")))); + when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED",null)))); } static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) { return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java index a71d9901c..cbd540d9d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java @@ -11,11 +11,21 @@ import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.*; -class WarehouseCalendarMapperTest { +public class WarehouseCalendarMapperTest { + /** H2 测试替身按真实 JSON 内容读取柜次,不把样例值写死到 SQL。 */ + public static String jsonExtract(String json,String path) throws Exception { + return json==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).path(path.substring(2)).toString(); + } + /** 测试替身复用 JSON 解码语义。 */ + public static String jsonUnquote(String value) throws Exception { + return value==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).asText(); + } /** 同柜跨客户不重复,人工改期与已建计划日期分别保留。 */ @Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception { var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1"); try (var connection=ds.getConnection(); var sql=connection.createStatement()) { + sql.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonExtract'"); + sql.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonUnquote'"); sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR," + "overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," + "estimated_warehouse_at TIMESTAMP,arrival_time_version INT)"); @@ -26,6 +36,12 @@ class WarehouseCalendarMapperTest { "(3,'A','2','CONT2','W','Warehouse','W','2026-11-10 09:00:00',2)," + "(4,'A','3','CONT3','W','Warehouse','W','2026-11-12 09:00:00',0)"); sql.execute("INSERT INTO ll_inbound_plan VALUES(4,'A')"); + sql.execute("ALTER TABLE ll_order ADD source_event_id VARCHAR"); + sql.execute("ALTER TABLE ll_inbound_plan ADD planned_arrival_date DATE"); + sql.execute("UPDATE ll_inbound_plan SET planned_arrival_date='2026-11-12'"); + sql.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR,payload_json VARCHAR)"); + sql.execute("INSERT INTO ll_loading_event_inbox VALUES('source-1','{\"cabinetNo\":\"D0001\"}')"); + sql.execute("UPDATE ll_order SET source_event_id='source-1' WHERE source_container_booking_id='1'"); } var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(WarehouseCalendarMapper.class); @@ -34,9 +50,11 @@ class WarehouseCalendarMapperTest { var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1")); assertEquals(3,rows.size()); assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt()); + assertEquals("D0001",rows.get(0).cabinetNo()); assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString()); - assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt()); - // 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。 + assertEquals(1,rows.get(2).planCount()); assertNull(rows.get(2).plannedAt()); + assertEquals(java.time.LocalDate.of(2026,11,12),rows.get(2).plannedDate()); + // 清关提柜与日历读取相同冻结事实,默认日期由共用服务端解析器返回。 var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3")); assertEquals(3,customs.size()); for(var calendar:rows) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java index 279e6902c..b70462747 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java @@ -10,10 +10,15 @@ import static org.mockito.Mockito.*; class WarehouseCalendarServiceTest { /** 读取资格不代替写权限,已有入库计划仍冻结时间。 */ @Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() { - var repository=mock(WarehouseCalendarRepository.class); var service=new WarehouseCalendarService(repository); + var repository=mock(WarehouseCalendarRepository.class); + var arrivals=mock(com.baiyue.lastleg.service.ContainerWarehouseArrivalService.class); + var service=new WarehouseCalendarService(repository,arrivals); var query=new WarehouseCalendarModels.Query(LocalDate.of(2026,11,1),LocalDate.of(2026,12,1),List.of("1")); - var open=new WarehouseCalendarModels.Entry("1","CONT","Warehouse","W",null,null,0,0,false); - var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,false); + var open=new WarehouseCalendarModels.Entry("1","D0001","CONT","Warehouse","W",null,null,0,0,false,null,false); + var frozen=new WarehouseCalendarModels.Entry("2","D0002","CONT2","Warehouse","W",null,null,1,1,false,null,false); + when(arrivals.resolve(List.of("1","2"))).thenReturn(List.of( + new com.baiyue.lastleg.dto.ContainerArrivalTimeView("1",null,null,0,0,LocalDate.of(2026,11,2),true), + new com.baiyue.lastleg.dto.ContainerArrivalTimeView("2",null,null,1,1,LocalDate.of(2026,11,3),false))); when(repository.calendar(query)).thenReturn(List.of(open,frozen)); assertTrue(service.calendar(query,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit()); assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 5b367fa19..f717ac4e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -93,7 +93,7 @@ public class DispatchAggregatePersistenceTest { var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class); var d=resources.driver();var proof=d.evidence(); var next=new DispatchContracts.Resources(resources.vehicle(),new DispatchContracts.DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(), - new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L))); + new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)), null); e.f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP', resources_json=? WHERE dispatch_no=?",e.f.json.writeValueAsString(next),number); var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; var uploaded=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"pickup.png","image/png",3L); @@ -167,7 +167,7 @@ public class DispatchAggregatePersistenceTest { String dispatch(ResourceMode mode,Long driverId,List ids) throws Exception { var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of()))); var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789", - new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of()))); + new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())), null); f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo(); } /** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */ @@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest { /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); - var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true); + var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true, null); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)); assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero(); assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); @@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest { } @Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input)); - var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true); + var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true, null); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划"); var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java index 1e2e0e22b..01c80c0ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java @@ -186,7 +186,7 @@ class DispatchBolReadPersistenceTest { db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))"); db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))"); var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class); - var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row); + var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null, null)));when(roots.find("DP-ONE")).thenReturn(row); service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper()); tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java index 4af5a43be..a8ec56d61 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java @@ -17,7 +17,7 @@ class DispatchCapacityDemandPersistenceTest { var service=new DispatchCapacityDemandService(f.repository,f.json); assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo()); assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue(); - var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false); + var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false, null); var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse(); assertThat(service.page(query()).total()).isZero(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java new file mode 100644 index 000000000..dcebf01ac --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java @@ -0,0 +1,178 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 维护资料与车辆照片经真实聚合、Fleet资源解析、持久化及审核往返。 */ +class DispatchMaintenancePersistenceTest { + /** 主档和临时车辆均保留本趟照片与八项资料,不能被Fleet主档重建吞掉。 */ + @ParameterizedTest + @ValueSource(strings = {"MASTER", "EXTERNAL"}) + void maintenanceAndVehiclePhotosSurviveReplayAndBothReviews(String mode) throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("maintenance-create", List.of(f.source(1, "101", 501, "ONT8", 3))); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var vehicle = vehicle(f, mode, List.of(301L)); + var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), " FBA自提 ", "一提多卸", "混装", + " fleet@example.test ", "厢式货车", " account ", " appointment "); + var request = new DispatchCommands.Update(created.recordVersion(), "maintenance-save", vehicle, input.driver(), + input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", true, maintenance); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)); + assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue(); + f.approve(saved); + var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())); + assertThat(detail.vehicleInfo().maintenance()).isEqualTo(maintenance); + assertThat(detail.vehicleInfo().maintenance().appointmentAccount()).isEqualTo("account"); + assertThat(detail.vehicleInfo().vehicle().evidenceFileIds()).containsExactly(301L); + assertThat(detail.dispatchInfo().plannedDepartureAt()).isEqualTo(input.plannedDepartureAt()); + assertThat(detail.dispatchInfo().departedAt()).isNull(); + var persisted = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class); + assertThat(persisted.maintenance()).isEqualTo(maintenance); + assertThat(persisted.vehicle().evidenceFileIds()).containsExactly(301L); + assertThat(f.repository.find(created.dispatchNo()).getRemark()).isEqualTo("保留原备注"); + verify(files(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.VEHICLE, 301L); + if (mode.equals("MASTER")) { + assertThat(detail.vehicleInfo().vehicle().plate()).isEqualTo("MASTER-71"); + assertThat(detail.vehicleInfo().vehicle().capacity().palletSpaces()).isEqualTo(15); + } + } + + /** 跨车、跨分类及失效照片的提供者拒绝必须整体回滚,MASTER同样不能绕过。 */ + @ParameterizedTest + @ValueSource(strings = {"跨车", "错分类", "已失效"}) + void invalidVehiclePhotoCannotChangeDraft(String reason) throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("invalid-vehicle-create", List.of()); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo()); + var dispatchId = f.repository.find(created.dispatchNo()).getId(); + doThrow(new IllegalArgumentException(reason)).when(files(f)).metadata(dispatchId, DispatchEvidenceGateway.Kind.VEHICLE, 301L); + var request = new DispatchCommands.Update(created.recordVersion(), "invalid-vehicle-save", vehicle(f, "MASTER", List.of(301L)), + input.driver(), input.plannedDepartureAt(), null, null, true, null); + assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))).hasMessageContaining(reason); + assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='invalid-vehicle-save'", Long.class)).isZero(); + } + + /** 维护整体为空或八字段为空均可保存空草稿,清空资料不会制造实际发车或正式预约。 */ + @Test void emptyMaintenanceCanBeSavedAndCleared() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var created = f.tx.execute(t -> f.service.create(f.actor, f.create("empty-maintenance", List.of()))); + var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"appointmentNo\":\"\"}", Maintenance.class); + assertThat(empty).isEqualTo(new Maintenance(null, null, null, null, null, null, null, null)); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update( + created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, true, empty))); + f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update( + saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, true, null))); + var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())); + assertThat(detail.vehicleInfo().maintenance()).isNull(); + assertThat(detail.dispatchInfo().departedAt()).isNull(); + } + + /** 文本仅限约定选项和长度,ISO日期不能接受时间串或不存在的日期。 */ + @Test void maintenanceRejectsUnknownOptionsAndOverlongText() throws Exception { + var json = new DispatchAggregatePersistenceTest.Fixture().json; + for (String field : List.of("deliveryMethod", "pickupMethod", "loadType", "vehicleType")) { + assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "未知"), Maintenance.class)) + .hasRootCauseInstanceOf(IllegalArgumentException.class); + } + for (String field : List.of("fleetEmail", "appointmentAccount", "appointmentNo")) { + int max = field.equals("fleetEmail") ? 254 : 128; + assertThat(json.treeToValue(json.createObjectNode().put(field, "a".repeat(max)), Maintenance.class)).isNotNull(); + assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "a".repeat(max + 1)), Maintenance.class)) + .hasRootCauseInstanceOf(IllegalArgumentException.class); + } + for (String date : List.of("2026-10-32", "2026-02-29", "2026-10-21T10:00:00", "2026/10/21")) { + assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", date), Maintenance.class)).isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class); + } + var parsed = json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", "2026-10-21"), Maintenance.class); + assertThat(parsed.plannedDeliveryDate()).isEqualTo(LocalDate.of(2026, 10, 21)); + assertThat(json.valueToTree(parsed).path("plannedDeliveryDate").asText()).isEqualTo("2026-10-21"); + } + + /** 取货资源投影和照片保存同样保留正式维护资料,不能在重建快照时掉字段。 */ + @Test void transportResourceRebuildAndPhotoSaveKeepMaintenance() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("projection-maintenance-create", List.of()); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, "AP-LOCAL"); + f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(created.recordVersion(), + "projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, true, maintenance))); + var projection = new com.baiyue.lastleg.dispatch.transport.TransportTaskProjection(f.repository, + mock(com.baiyue.lastleg.dispatch.transport.TransportTaskRepository.class), + mock(com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository.class), mock(DispatchNumberGenerator.class), f.json); + var resources = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class); + f.tx.executeWithoutResult(t -> projection.savePhotos(created.dispatchNo(), f.actor.operatorId(), List.of(302L), resources.driver().evidence())); + var row = f.repository.find(created.dispatchNo()); + assertThat(f.json.readValue(row.getResourcesJson(), Resources.class).maintenance()).isEqualTo(maintenance); + var trip = new com.baiyue.lastleg.fleet.trip.FleetTripEntity(); + trip.setVehicleId(71L); trip.setVehiclePlate("MASTER-71"); + ReflectionTestUtils.invokeMethod(projection, "resources", row, trip); + var rebuilt = f.json.readValue(row.getResourcesJson(), Resources.class); + assertThat(rebuilt.maintenance()).isEqualTo(maintenance); + assertThat(rebuilt.vehicle().evidenceFileIds()).containsExactly(302L); + } + + /** 请求、资源与详情共用可空维护结构;文档中的每个选项都必须被正式DTO接受。 */ + @Test void openApiPublishesSymmetricNullableMaintenanceContract() throws Exception { + var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.JsonNode root; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + root = json.valueToTree(new org.yaml.snakeyaml.Yaml().load(input)); + } + var schemas = root.path("components").path("schemas"); + for (String name : List.of("DispatchUpdate", "DispatchResources", "DispatchVehicleInfo")) { + var field = schemas.path(name).path("properties").path("maintenance"); + assertThat(field.path("nullable").asBoolean()).isTrue(); + assertThat(field.path("allOf").get(0).path("$ref").asText()).isEqualTo("#/components/schemas/DispatchMaintenance"); + for (var required : schemas.path(name).path("required")) assertThat(required.asText()).isNotEqualTo("maintenance"); + } + var schema = schemas.path("DispatchMaintenance"); + var fields = schema.path("properties"); + assertThat(fields.size()).isEqualTo(Maintenance.class.getRecordComponents().length); + assertThat(schema.path("required").isMissingNode()).isTrue(); + for (var field : Maintenance.class.getRecordComponents()) { + assertThat(fields.path(field.getName()).path("nullable").asBoolean()).isTrue(); + for (var value : fields.path(field.getName()).path("enum")) { + assertThat(json.treeToValue(json.createObjectNode().put(field.getName(), value.asText()), Maintenance.class)).isNotNull(); + } + } + assertThat(fields.path("plannedDeliveryDate").path("format").asText()).isEqualTo("date"); + assertThat(fields.path("fleetEmail").path("maxLength").asInt()).isEqualTo(254); + assertThat(fields.path("appointmentAccount").path("maxLength").asInt()).isEqualTo(128); + assertThat(fields.path("appointmentNo").path("maxLength").asInt()).isEqualTo(128); + } + + /** 主档请求只携带身份与本趟照片,名称及容量必须仍取权威主档。 */ + private static DispatchCommands.Vehicle vehicle(DispatchAggregatePersistenceTest.Fixture f, String mode, List photos) { + if (mode.equals("MASTER")) { + var vehicles = (VehicleRepository) ReflectionTestUtils.getField(f.nativeFleetService, "vehicles"); + var actual = new VehicleRepository.VehicleSnapshot(71L, "MASTER-71", null, null, 0L, "主档车队", null, + "OVERSEAS", "OWNED", "IN_SERVICE", null, 0L, null, null, 15, new BigDecimal("10000"), new BigDecimal("50")); + when(vehicles.findById(71L)).thenReturn(Optional.of(actual)); + return new DispatchCommands.Vehicle(ResourceMode.MASTER, 71L, "不能覆盖主档", null, null, photos); + } + return new DispatchCommands.Vehicle(ResourceMode.EXTERNAL, null, "EXTERNAL-71", "本趟车队", + new Capacity(15, new BigDecimal("10000"), new BigDecimal("50")), photos); + } + + /** 附件提供者仍执行本车及分类边界;只隔离外部File服务。 */ + private static DispatchEvidenceGateway files(DispatchAggregatePersistenceTest.Fixture f) { + return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java index 8a3f4cdba..464afa4b9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java @@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest { var e = d.evidence(); var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos)); - return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true); + return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true, null); } /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index f8705f263..628852cc0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest { long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓"))); var resources=f.configured("updated-resources",List.of()); - var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true))); + var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true, null))); var detail=f.service.detail(f.actor,created.dispatchNo()); var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java index 127eea75e..95e4d02e5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java @@ -55,13 +55,13 @@ class TransportReviewPhotosTest { /** 换司机后旧人像不允许重复绑定。 */ @Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception { row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000", - new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null))); assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传"); } /** 审核复验已保存证据,照片后续失效也不能通过。 */ @Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception { row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)), - new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null))); service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L); when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效")); assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index b95f2a579..3e60ea53a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest { when(reader.detail(any(),anyString())).thenAnswer(call->{ var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), - DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true); + DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver(), null),null,null,List.of(),true); }); queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec)); @@ -67,7 +67,7 @@ class OwnedBusinessScopePersistenceTest { var original=OwnedBusinessFixtures.source(); var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class); var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of()); - String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver())); + String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver(), null)); String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id)); baseline.jdbc.update(""" INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 9df6a6190..c58703e0c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -58,12 +58,14 @@ class InboundPlanServiceTest { @Mock WarehouseResourceGateway warehouseGateway; @Mock LastlegOrderCommandRecordMapper commandMapper; @Mock LastlegSourcePalletMapper sourcePalletMapper; + @Mock com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival; private InboundPlanService service; @BeforeEach void setUp() { service = new InboundPlanService(); + ReflectionTestUtils.setField(service,"warehouseArrival",warehouseArrival); ReflectionTestUtils.setField(service, "planMapper", planMapper); ReflectionTestUtils.setField(service, "lineMapper", lineMapper); ReflectionTestUtils.setField(service, "palletMapper", palletMapper); @@ -95,8 +97,8 @@ class InboundPlanServiceTest { "100", 2L, "BOOKING-1", "193000000000200003"))); org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong())) .thenReturn("BOOKING-1"); - org.mockito.Mockito.lenient().when(orderMapper.containerEstimatedWarehouseTimes(any())) - .thenReturn(List.of(LocalDateTime.of(2026, 9, 15, 8, 0))); + org.mockito.Mockito.lenient().when(warehouseArrival.requiredDate(any())) + .thenReturn(java.time.LocalDate.of(2026, 9, 15)); } @Test @@ -176,15 +178,16 @@ class InboundPlanServiceTest { void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() { ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, 2, "STANDARD", true, true, true, "优先收货"); - when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of()); + when(warehouseArrival.requiredDate("BOOKING-1")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间缺失,请先补齐柜级时间")); ResponseStatusException missing = assertThrows(ResponseStatusException.class, () -> service.convertManual(request, "manual-missing-arrival", "900")); assertEquals(409, missing.getStatusCode().value()); assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason()); - when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of( - LocalDateTime.of(2026, 9, 15, 8, 0), LocalDateTime.of(2026, 9, 16, 8, 0))); + org.mockito.Mockito.doThrow(new ResponseStatusException( + org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间不一致,请先修正柜级时间")) + .when(warehouseArrival).requiredDate("BOOKING-1"); ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class, () -> service.convertManual(request, "manual-inconsistent-arrival", "900")); assertEquals(409, inconsistent.getStatusCode().value()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalServiceTest.java new file mode 100644 index 000000000..12e3444dd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ContainerWarehouseArrivalServiceTest { + private final ContainerWarehouseArrivalRepository repository=mock(ContainerWarehouseArrivalRepository.class); + private final AppointmentForecastClient forecasts=mock(AppointmentForecastClient.class); + private final ContainerWarehouseArrivalService service=new ContainerWarehouseArrivalService(repository,forecasts,"internal-test"); + + /** 默认日期跨月按当地自然日推进,展示和计划创建必须读取同一结果。 */ + @Test void defaultsAcrossMonthAndUsesExactlyTheSameDateForPlanning() { + when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false))); + when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of( + new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",LocalDateTime.of(2026,10,30,23,30))))); + var result=service.resolve(List.of("1")).get(0); + assertEquals(LocalDate.of(2026,11,1),result.plannedDate()); + assertNull(result.estimatedWarehouseArrivalAt()); + assertTrue(result.isDefault()); + assertEquals(result.plannedDate(),service.requiredDate("1")); + } + + /** 人工维护优先,已生成计划后的日期不再随到港变化重新计算。 */ + @Test void manualAndFrozenDatesNeverReadNewForecasts() { + var manual=LocalDateTime.of(2026,11,3,9,30); + when(repository.find(List.of("1","2"))).thenReturn(List.of( + new ContainerArrivalTimeView("1",manual,null,1,0,null,false), + new ContainerArrivalTimeView("2",null,null,0,1,LocalDate.of(2026,11,4),false))); + var result=service.resolve(List.of("1","2")); + assertEquals(manual.toLocalDate(),result.get(0).plannedDate()); + assertEquals(LocalDate.of(2026,11,4),result.get(1).plannedDate()); + assertFalse(result.get(1).isDefault()); + verifyNoInteractions(forecasts); + } + + /** 真实未知日期不补零点,提供方漏行也不能当作完整空事实。 */ + @Test void unknownAndIncompleteProviderResultsBlockPlanCreation() { + when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false))); + when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of( + new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",null)))); + assertNull(service.resolve(List.of("1")).get(0).plannedDate()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value()); + when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of())); + assertEquals(503,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value()); + } + + /** 同柜版本或已建计划日期冲突时不能被默认到港算法覆盖。 */ + @Test void inconsistentOrMissingFrozenDatesRemainBlocked() { + for(var row:List.of(new ContainerArrivalTimeView("1",null,null,-1,0,null,false), + new ContainerArrivalTimeView("1",null,null,1,0,null,false), + new ContainerArrivalTimeView("1",null,null,0,2,null,false))) { + when(repository.find(List.of("1"))).thenReturn(List.of(row)); + assertThrows(ResponseStatusException.class,()->service.requiredDate("1")); + } + verifyNoInteractions(forecasts); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index e6f0e86a7..0e886ee52 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -8,7 +8,7 @@ class LastlegOrderNoGeneratorTest { /** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */ @Test void routesEveryOperationalTypeToItsDailySequence() { var generator=TestDocumentNumbers.orders(); - Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"), + Map.ofEntries(Map.entry("LASTLEG","WC"),Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"), Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"), Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"), Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"), @@ -19,12 +19,4 @@ class LastlegOrderNoGeneratorTest { }); assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN")); } - /** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */ - @Test void actualContainerNumberRemainsStableAndShared() { - var generator=TestDocumentNumbers.orders(); - assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}")); - assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870")); - assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383")); - assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" ")); - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index ddd8685d2..7cd7faa73 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -117,7 +117,7 @@ class LastlegOrderServiceTest { void setUp() { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class); - service = new LastlegOrderService(); + service = new LastlegOrderService(); ReflectionTestUtils.setField(service,"warehouseArrival", new ContainerWarehouseArrivalService(new com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository(orders), org.mockito.Mockito.mock(com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient.class), "test")); ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities); ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences); ReflectionTestUtils.setField(service, "orderMapper", orders); @@ -148,7 +148,7 @@ class LastlegOrderServiceTest { when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L); when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2); when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of( - new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0))); + new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0, estimatedAt.toLocalDate(), false))); ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1", new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 5f43e3f66..1aab0bfb9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -2,11 +2,42 @@ package com.baiyue.lastleg.service; import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { + /** 两个来源柜复用同一实箱时仍各自取号,不能把历史箱号当本次柜身份。 */ + @Test void reusedPhysicalContainerKeepsDistinctSourceBookingNumbers() { + when(sequences.next("WC")).thenReturn("WC202610210001","WC202610210002"); + EventEnvelope first=event(); service.consume(first); + com.fasterxml.jackson.databind.node.ObjectNode payload=first.payload().deepCopy(); + payload.put("containerBookingId","book-2"); + EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",first.eventType(),first.eventVersion(), + first.occurredAt(),first.tenantId(),first.aggregateType(),first.aggregateId(),first.aggregateVersion(), + first.handoverVersion(),first.traceId(),first.correlationId(),null,payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + service.consume(second); + ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders,times(4)).insert(captured.capture()); + assertEquals(2,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); + verify(orders).containerOrderNumbers("book-1"); verify(orders).containerOrderNumbers("book-2"); + } + /** 同柜后续客户交接沿用首票编号,不再次占用日流水。 */ + @Test void reusesTheExistingContainerNumberWithoutAllocatingAgain() { + when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001")); + service.consume(event()); + ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders,times(2)).insert(captured.capture()); + assertTrue(captured.getAllValues().stream().allMatch(order->"WC202610200001".equals(order.getOrderNo()))); + verify(sequences,never()).next(anyString()); + } + /** 同柜身份不一致必须停止,不能挑选任一旧号继续生成订单。 */ + @Test void rejectsConflictingContainerBusinessNumbers() { + when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001","WC202610200002")); + assertThrows(IllegalStateException.class,()->service.consume(event())); + verify(orders,never()).insert(any(LastlegOrder.class)); + } private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); } + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"orderNumbers",new com.baiyue.lastleg.repository.LastlegOrderNumberRepository(orders)); lenient().when(sequences.next("WC")).thenReturn("WC202610210001"); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("WC[0-9]{8}[0-9]{4}")); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^WC\\d{8}\\d{4}$")); verify(sequences).next("WC"); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); }