修复费用记录、装柜完成及海外履约资料

This commit is contained in:
2026-10-21 23:20:09 +08:00
parent 5356a8c898
commit 2c3435adaf
62 changed files with 1622 additions and 298 deletions
@@ -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 " +
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
" AND f.status&lt;&gt;'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&gt;f.source_fee_version)" +
"</if>" +
"<if test='customerId != null'> AND f.customer_id=#{customerId}</if>" +
"<if test='direction != null'> AND f.direction=#{direction}</if>" +
"<if test='reviewStatus != null'> AND f.review_status=#{reviewStatus}</if>" +
"<if test='status != null'> AND " + STATUS + "=#{status}</if>" +
"<if test='statementGenerated != null'> AND " + GENERATED + "=#{statementGenerated}</if>" +
"<if test='dateFrom != null'> AND " + BUSINESS_DATE + "&gt;=#{dateFrom}</if>" +
"<if test='dateTo != null'> AND " + BUSINESS_DATE + "&lt;=#{dateTo}</if>" +
"<if test='keyword != null'> 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},'%'))</if>";
/** 筛选和计数共用当前版本条件,不能先分页再去重。 */
@Select("<script>SELECT COUNT(*)" + FILTER + "</script>")
long count(Map<String, Object> filters);
/** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS + " effective_status" + FILTER +
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<Map<String, Object>> list(Map<String, Object> 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&lt;=anchor.source_fee_version";
/** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */
@Select("<script>SELECT f.id,f.source_fee_version,f.amount,f.currency_code,f.created_at" + LINEAGE +
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId}" +
" AND f.source_system IN ('FIRSTLEG','LASTLEG') ORDER BY f.source_fee_version,f.id</script>")
List<Map<String, Object>> versions(@Param("customerId") long customerId, @Param("id") long id);
/** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */
@Select("<script>SELECT a.id,a.created_at,a.action,a.operator_name_snapshot,a.reason,a.before_data,a.after_data" + LINEAGE +
" JOIN fin_audit_log a ON a.tenant_id=f.customer_id AND a.resource_type='FIN_FEE_SOURCE' AND a.resource_id=f.id" +
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId} ORDER BY a.created_at DESC,a.id DESC</script>")
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("id") long id);
}
@@ -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<String, Object> filters(Long customerId, String direction, String status, String reviewStatus,
Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) {
Map<String, Object> 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<String, Object> filters) { return mapper.count(filters); }
/** 当前费用映射沿用原币、数量、客户等正式字段。 */
public List<FeeSourceSummary> list(Map<String, Object> filters) {
return mapper.list(filters).stream().map(this::summary).toList();
}
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
public List<OperationLog> logs(long customerId, long id) {
List<OperationLog> result = new ArrayList<>();
Map<String, Object> previous = null;
for (Map<String, Object> raw : mapper.versions(customerId, id)) {
Map<String, Object> 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<String, Object> raw : mapper.audits(customerId, id)) {
Map<String, Object> 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<String, Object> raw) {
Map<String, Object> 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<String, Object> row(Map<String, Object> source) {
Map<String, Object> 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<String, Object> 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> T first(T value, T fallback) { return value == null ? fallback : value; }
}
@@ -1,10 +1,10 @@
package com.baiyue.finance.service; package com.baiyue.finance.service;
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; 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.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
@@ -44,19 +44,19 @@ public class FinancePhase1OperationService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final FirstlegFeeSourceClient firstleg; private final FirstlegFeeSourceClient firstleg;
private final String internalToken; private final String internalToken;
private final FinanceFeeSourceReadRepository feeSources;
/** 注入费用来源查询及现有业务依赖。 */
@Autowired @Autowired
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper, public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FirstlegFeeSourceClient firstleg, FirstlegFeeSourceClient firstleg,
@Value("${baiyue.security.internal-service-token}") String internalToken) { @Value("${baiyue.security.internal-service-token}") String internalToken,
FinanceFeeSourceReadRepository feeSources) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.firstleg = firstleg; this.firstleg = firstleg;
this.internalToken = internalToken; this.internalToken = internalToken;
} this.feeSources = feeSources;
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, null, "");
} }
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) { public PageEnvelope<AccountSummary> 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()); return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
} }
/** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId, public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
String direction, String sourceStatus, String direction, String sourceStatus,
String reviewStatus, String reviewStatus,
@@ -178,34 +179,9 @@ public class FinancePhase1OperationService {
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus, String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); 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)"; Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END"; normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')"; return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
List<Object> 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<Object> 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<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
} }
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) { public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
@@ -339,6 +315,7 @@ public class FinancePhase1OperationService {
return after; return after;
} }
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
private FeeSourceDetail detail(ResultSet rs) throws SQLException { private FeeSourceDetail detail(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot")); JsonNode snapshot = parse(rs.getString("source_snapshot"));
long id = rs.getLong("id"); 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, amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
defaultString(rs.getString("review_status"), "DRAFT"), status, generated, 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)); rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.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<OperationLog> 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);
} }
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) { private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
@@ -17,6 +17,7 @@ class FinanceContractTest {
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class)); .getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
} }
/** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */
@Test @Test
void controllersUseFrozenFinancePaths() throws Exception { void controllersUseFrozenFinancePaths() throws Exception {
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]); 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(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())")); assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java")); 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\")")); assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
assertFalse(operations.contains("bl.source_id=f.source_fee_id")); assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
@@ -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();
}
}
@@ -9,10 +9,13 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinancePhase1OperationService; import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
import com.baiyue.finance.service.FinanceCustomerAccountService; import com.baiyue.finance.service.FinanceCustomerAccountService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 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.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate; 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.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; 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) @ExtendWith(MockitoExtension.class)
class FinancePhase1OperationBehaviorTest { class FinancePhase1OperationBehaviorTest {
@@ -81,18 +87,20 @@ class FinancePhase1OperationBehaviorTest {
assertThat(response.page().total()).isEqualTo(1); assertThat(response.page().total()).isEqualTo(1);
} }
/** 无效客户在进入持久化查询前拒绝。 */
@Test @Test
void ledgerRejectsInvalidCustomerBeforeAnyQuery() { 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)) assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
.isInstanceOf(FinanceEventException.class) .isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
} }
/** 保留客户账户汇总的固定分页与金额口径。 */
@Test @Test
void accountListReturnsFixedPagedDto() { void accountListReturnsFixedPagedDto() {
AccountJdbc jdbc = new AccountJdbc(); AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
PageEnvelope<FinancePhase1Dtos.AccountSummary> result = PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE"); service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
@@ -112,10 +120,11 @@ class FinancePhase1OperationBehaviorTest {
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
} }
/** 所有派生流水查询保留目标客户范围。 */
@Test @Test
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() { void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
AccountJdbc jdbc = new AccountJdbc(); AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result = PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
@@ -128,58 +137,24 @@ class FinancePhase1OperationBehaviorTest {
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'")); .contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
} }
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
@Test @Test
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception { void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
JdbcTemplate jdbc = mock(JdbcTemplate.class); var reads = mock(FinanceFeeSourceReadRepository.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class))) var filters = java.util.Map.<String, Object>of("query", "current");
.thenReturn(1L); when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1); when(reads.list(filters)).thenReturn(List.of());
ResultSet rs = mock(ResultSet.class); when(reads.count(filters)).thenReturn(3L);
when(rs.getLong("id")).thenReturn(88L); var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
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());
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved", var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9"); false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page().total()).isEqualTo(3);
assertThat(result.page()).satisfies(meta -> { assertThat(result.page().page()).isEqualTo(2);
assertThat(meta.page()).isEqualTo(2); verify(reads).list(filters);
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));
} }
/** 流水返回业务单号和操作者快照。 */
@Test @Test
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception { void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); 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,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()); 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<FinancePhase1Dtos.LedgerEntry> result = PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
@@ -211,18 +186,20 @@ class FinancePhase1OperationBehaviorTest {
}); });
} }
/** 保留费用审核状态读取。 */
@Test @Test
void approvedFeeCannotBeEditedAsDraft() throws Exception { void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); 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"); assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
} }
/** 费用详情保持原客户与来源版本。 */
@Test @Test
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
FinancePhase1OperationService service = new FinancePhase1OperationService( FinancePhase1OperationService service = new FinancePhase1OperationService(
feeSourceJdbc("APPROVED", true, 2), objectMapper()); feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FeeSourceDetail detail = service.getFinanceFeeSource(88L); FeeSourceDetail detail = service.getFinanceFeeSource(88L);
@@ -232,10 +209,11 @@ class FinancePhase1OperationBehaviorTest {
assertThat(detail.reviewStatus()).isEqualTo("APPROVED"); assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
} }
/** 业务来源费用必须回到所属系统调整。 */
@Test @Test
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception { void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1); 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( var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1); new BigDecimal("120.00"), "更正金额", 1);
@@ -247,6 +225,7 @@ class FinancePhase1OperationBehaviorTest {
(Object[]) any()); (Object[]) any());
} }
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
@Test @Test
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception { void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2); JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
@@ -257,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult( .thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false))); "INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService( FinancePhase1OperationService service = new FinancePhase1OperationService(
jdbc, objectMapper(), firstleg, "internal-token"); jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance", FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2)); "void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
@@ -278,6 +257,7 @@ class FinancePhase1OperationBehaviorTest {
(Object[]) any()); (Object[]) any());
} }
/** 审核以当前操作版本更新并追加同一记录的审计。 */
@Test @Test
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -289,7 +269,6 @@ class FinancePhase1OperationBehaviorTest {
return List.of(mapper.mapRow(resultSet(review, false, version), 0)); 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.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), when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), 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); eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), 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); 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", FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); 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)); 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 { private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { 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)); 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.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), lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
return jdbc; return jdbc;
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
@@ -55,28 +56,34 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
int claim(@Param("id") long id, @Param("worker") String worker, int claim(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil); @Param("leaseUntil") LocalDateTime leaseUntil);
/** 只有尚未过期的当前所有者可以续租,过期执行者必须重新领取。 */
@Update(""" @Update("""
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3) UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker} WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
AND lease_until>CURRENT_TIMESTAMP(3)
""") """)
int renew(@Param("id") long id, @Param("worker") String worker, int renew(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil); @Param("leaseUntil") LocalDateTime leaseUntil);
/** 完成落库必须仍持有有效租约,拒绝过期或已易主的旧执行者。 */
@Update(""" @Update("""
UPDATE fl_loading_completion_command UPDATE fl_loading_completion_command
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3), 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, lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
failure_request_id=NULL,failure_trace_id=NULL failure_request_id=NULL,failure_trace_id=NULL
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker} 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); int complete(@Param("id") long id, @Param("worker") String worker);
/** 失败回执也受当前有效租约保护,不能覆盖新执行者的状态。 */
@Update(""" @Update("""
UPDATE fl_loading_completion_command UPDATE fl_loading_completion_command
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3), 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}, lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
failure_request_id=#{requestId},failure_trace_id=#{traceId} failure_request_id=#{requestId},failure_trace_id=#{traceId}
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker} 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, int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage, @Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
@@ -97,13 +104,32 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId, String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId); @Param("inboundId") long inboundId);
/** 检查点事务锁住当前有效租约,与并发重新领取互斥。 */
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
@Select("""
SELECT id FROM fl_loading_completion_command
WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker}
AND lease_until>CURRENT_TIMESTAMP(3)
FOR UPDATE
""")
Long lockCheckpointLease(@Param("commandId") long commandId, @Param("worker") String worker);
/** 在已锁定的命令行上重验有效租约;检查点只能前进,不能被迟到写入降级。 */
@Insert(""" @Insert("""
INSERT INTO fl_loading_completion_checkpoint INSERT INTO fl_loading_completion_checkpoint
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage) (command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage}) SELECT id,#{tenantId},#{inboundId},#{sequence},#{stage}
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3) 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("inboundId") long inboundId, @Param("sequence") int sequence,
@Param("stage") String stage); @Param("stage") String stage);
} }
@@ -11,16 +11,27 @@ import java.util.stream.Collectors;
@Service @Service
public class AppointmentForecastService { public class AppointmentForecastService {
public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest, 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; 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<Forecast> batch(List<Long> ids) { public List<Forecast> batch(List<Long> ids) {
if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0) 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个不重复的柜次"); || 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 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->{ return ids.stream().map(id->{
var rows=facts.getOrDefault(id,List.of()); 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(); }).toList();
} }
} }
@@ -4,6 +4,7 @@ import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -12,6 +13,9 @@ import java.util.List;
public class LoadingCompletionCommandRepository { public class LoadingCompletionCommandRepository {
@Resource private LoadingCompletionCommandMapper mapper; @Resource private LoadingCompletionCommandMapper mapper;
/** 按事务提交后的命令标识读取唯一完成请求。 */
public LoadingCompletionCommandEntity command(long id) { return mapper.selectById(id); }
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); } public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); } public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); } 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) { public String checkpoint(long commandId, long tenantId, long inboundId) {
return mapper.checkpoint(commandId, tenantId, 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;
} }
} }
@@ -9,37 +9,77 @@ import jakarta.annotation.Resource;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
/** Lease-based recovery worker for PDA completion commands. */ /** 事务提交后立即处理 PDA 完成命令,并按持久化租约定时恢复。 */
@Service @Service
public class LoadingCompletionWorker { public class LoadingCompletionWorker {
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class); private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
private static final int MAX_ATTEMPTS = 5; private static final int MAX_ATTEMPTS = 5;
private final String workerId = "pda-completion-" + UUID.randomUUID();
@Resource private LoadingCompletionCommandRepository commands; @Resource private LoadingCompletionCommandRepository commands;
@Resource private LoadingPickRepository picks; @Resource private LoadingPickRepository picks;
@Resource private LoadingPickService loadingPicks; @Resource private LoadingPickService loadingPicks;
@Resource(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}") /** 唤醒只发生在受理事务提交后,异步执行不延长 HTTP 受理请求。 */
public void dispatch() { @TransactionalEventListener
for (LoadingCompletionCommandEntity candidate : commands.due(10)) { public void accepted(LoadingPickService.CompletionAccepted accepted) {
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) { try {
continue; completionExecutor.execute(() -> dispatchAccepted(accepted));
} } catch (RejectedExecutionException exception) {
try { // 执行器繁忙或关闭时保留持久化命令,仍由定时扫描领取,不能影响已提交回执。
loadingPicks.processCompletion(candidate, workerId); log.warn("pda_completion_wakeup_deferred commandId={}", accepted.commandId(), exception);
} catch (RuntimeException exception) {
handleFailure(candidate, 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 Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
&& exception.getCause() != null ? exception.getCause() : exception; && exception.getCause() != null ? exception.getCause() : exception;
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
@@ -53,7 +93,7 @@ public class LoadingCompletionWorker {
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1; int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS; boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS;
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4))); 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); stage, code, requestId, traceId);
if (terminal && recorded) { if (terminal && recorded) {
picks.failCompletion(command.getPickTaskId()); picks.failCompletion(command.getPickTaskId());
@@ -63,6 +103,7 @@ public class LoadingCompletionWorker {
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause); command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
} }
/** 仅将已明确不可重试的本地业务门禁判为终态。 */
private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) { private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) {
if (!(cause instanceof BizException business)) { if (!(cause instanceof BizException business)) {
return false; return false;
@@ -56,6 +56,7 @@ import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -87,6 +88,8 @@ public class LoadingPickService {
@Resource @Resource
private LoadingCompletionCommandRepository completionCommands; private LoadingCompletionCommandRepository completionCommands;
@Resource @Resource
private ApplicationEventPublisher completionEvents;
@Resource
private TransactionTemplate completionTransaction; private TransactionTemplate completionTransaction;
@Resource @Resource
private SmartLoadingRepository smartLoadingRepository; private SmartLoadingRepository smartLoadingRepository;
@@ -552,6 +555,7 @@ public class LoadingPickService {
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) { || !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
throw conflict("装柜完成命令状态已变化,请刷新后重试"); throw conflict("装柜完成命令状态已变化,请刷新后重试");
} }
completionEvents.publishEvent(new CompletionAccepted(replay.getId(), System.nanoTime()));
} }
return view(requiredTask(taskId), null); return view(requiredTask(taskId), null);
} }
@@ -598,9 +602,14 @@ public class LoadingPickService {
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) { if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化,请刷新后复核"); throw conflict("PDA任务版本已变化,请刷新后复核");
} }
// 提交后立即唤醒仍经持久化租约领取;回滚不得触发结算,定时扫描继续负责故障恢复。
completionEvents.publishEvent(new CompletionAccepted(command.getId(), System.nanoTime()));
return view(requiredTask(taskId), null); return view(requiredTask(taskId), null);
} }
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
private void requireCompletionContainerNumber(long taskId) { private void requireCompletionContainerNumber(long taskId) {
ContainerBookingEntity booking = repository.activeBookingByTask(taskId); ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { 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) { public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
long taskId = command.getPickTaskId(); long taskId = command.getPickTaskId();
inboundHoldGuard.requireTaskNotHeld(taskId); inboundHoldGuard.requireTaskNotHeld(taskId);
@@ -625,55 +634,55 @@ public class LoadingPickService {
.toList(); .toList();
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单")); if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
List<GroupSnapshot> taskGroups = coloadGroups(owners); List<GroupSnapshot> taskGroups = coloadGroups(owners);
runCompletionStage("CABINET_EVIDENCE", () -> runCompletionStage(taskId, "CABINET_EVIDENCE", () ->
cabinetEvidenceService.requireComplete(task.getContainerBookingId())); cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
runCompletionStage("COMPLETED_EVENT_READY", () -> runCompletionStage(taskId, "COMPLETED_EVENT_READY", () ->
loadingListService.requireCompletedEventReady(task.getLoadingPlanId())); 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++) { for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker); requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index); LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "SETTLED")) { if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( runCompletionStage(taskId, "OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
owner.tenantId(), owner.inboundId())); owner.tenantId(), owner.inboundId()));
if (!checkpointAtLeast(checkpoint, "READY")) { if (!checkpointAtLeast(checkpoint, "READY")) {
requireCompletionLease(command.getId(), worker); recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(),
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
index + 1, "READY"); 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++) { for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker); requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index); LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "SETTLED")) { if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner( runCompletionStage(taskId, "WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName())); taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
requireCompletionLease(command.getId(), worker); recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
} }
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) { if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( runCompletionStage(taskId, "INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
owner.tenantId(), owner.inboundId(), owner.tenantId(), owner.inboundId(),
command.getOperatorId(), command.getOperatorName(), command.getOperatorId(), command.getOperatorName(),
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark())); "PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
requireCompletionLease(command.getId(), worker); recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1,
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
"INBOUND_COMPLETED"); "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, private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) { LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
long taskId = task.getId(); long taskId = task.getId();
@@ -688,11 +697,11 @@ public class LoadingPickService {
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING", insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
command.getOperatorId(), command.getOperatorName()); 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())); command.getOperatorId(), command.getOperatorName()));
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); runCompletionStage(taskId, "COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
for (GroupSnapshot group : taskGroups) { 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() + '|' task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName())); + 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, private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
String requestHash, long operatorId) { String requestHash, long operatorId) {
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId()) 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); 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 { try {
action.run(); action.run();
succeeded = true;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw new CompletionProcessingException(stage, 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);
} }
} }
@@ -9,6 +9,25 @@ import java.util.List;
@Mapper @Mapper
public interface ContainerCalendarSourceMapper { public interface ContainerCalendarSourceMapper {
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
@Select("""
<script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
FROM fl_container_booking c
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
WHERE c.record_status='ACTIVE' AND c.id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
</script>
""")
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */ /** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
@Select(""" @Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo, SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
@@ -11,6 +11,8 @@ public class ContainerCalendarSourceRepository {
private final ContainerCalendarSourceMapper mapper; private final ContainerCalendarSourceMapper mapper;
/** 使用本服务 Mapper 读取日历所需柜级来源。 */ /** 使用本服务 Mapper 读取日历所需柜级来源。 */
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; } public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
/** 内部批量读取明确柜次,日期口径与平台日历一致。 */
public List<ContainerCalendarSource> byIds(List<Long> ids) { return mapper.byIds(ids); }
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */ /** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) { public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(), return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
@@ -34,6 +34,7 @@ paths:
properties: properties:
containerBookingId: {type: string} containerBookingId: {type: string}
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]} timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
portArrivalAt: {type: string, format: date-time, nullable: true, description: 与预约日历相同的权威到港当地时间,最新有效实际到港优先,其次柜资料实际到港及预计到港;未知保持空}
latest: {$ref: '#/components/schemas/Fact'} latest: {$ref: '#/components/schemas/Fact'}
previous: {$ref: '#/components/schemas/Fact'} previous: {$ref: '#/components/schemas/Fact'}
'401': {description: 内部身份缺失、错误或服务端未配置} '401': {description: 内部身份缺失、错误或服务端未配置}
@@ -9,8 +9,20 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class AppointmentForecastServiceTest { 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() { @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 observed=LocalDateTime.of(2026,10,10,12,0);
var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null); 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); 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); verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper);
} }
@Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() { @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.<Long>of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L))) for(var ids:List.of(List.<Long>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(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
@@ -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();
}
}
@@ -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_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_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_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)"); jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)");
row(1, 101, 50, 3, "PARTIAL", 2, 10, 0); row(1, 101, 50, 3, "PARTIAL", 2, 10, 0);
row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history
@@ -76,11 +78,11 @@ class LoadingCompletionOwnerPersistenceTest {
@Test @Test
void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() { void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() {
checkpoints.checkpointCompleted(900, 101, 50, 1, "READY"); checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "READY");
checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED"); checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "SETTLED");
checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED"); checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "INBOUND_COMPLETED");
checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED"); checkpoints.checkpointCompleted(900, "owner-test", 202, 60, 2, "INBOUND_COMPLETED");
checkpoints.checkpointCompleted(900, 303, 70, 3, "READY"); checkpoints.checkpointCompleted(900, "owner-test", 303, 70, 3, "READY");
assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED"); assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED");
assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY"); assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY");
@@ -8,22 +8,124 @@ import com.baiyue.firstleg.repository.LoadingPickRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; 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.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.concurrent.RejectedExecutionException;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingCompletionWorker.class) @SpringJUnitConfig({LoadingCompletionWorker.class, LoadingCompletionWorkerTest.TransactionEvents.class})
class LoadingCompletionWorkerTest { class LoadingCompletionWorkerTest {
/** 与生产事务装配一致,注册事务事件工厂,避免普通事件工厂立即调用监听器。 */
@Configuration
@EnableTransactionManagement
static class TransactionEvents {}
@MockBean private LoadingCompletionCommandRepository commands; @MockBean private LoadingCompletionCommandRepository commands;
@MockBean private LoadingPickRepository picks; @MockBean private LoadingPickRepository picks;
@MockBean private LoadingPickService loadingPicks; @MockBean private LoadingPickService loadingPicks;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
@jakarta.annotation.Resource private LoadingCompletionWorker worker; @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<Runnable> 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<Runnable> 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<String> 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 @Test
void transientFailureIsReleasedForBackoffRetry() { void transientFailureIsReleasedForBackoffRetry() {
@@ -50,6 +50,9 @@ import org.mockito.InOrder;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; 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.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
@@ -77,7 +80,9 @@ import static org.mockito.Mockito.clearInvocations;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(LoadingPickService.class) @SpringJUnitConfig(LoadingPickService.class)
@RecordApplicationEvents
class LoadingPickServiceTest { class LoadingPickServiceTest {
@Autowired private ApplicationEvents completionEvents;
@MockBean @MockBean
private LoadingPickRepository repository; private LoadingPickRepository repository;
@MockBean @MockBean
@@ -154,6 +159,8 @@ class LoadingPickServiceTest {
when(repository.activeBookingByTask(20L)).thenReturn(booking); when(repository.activeBookingByTask(20L)).thenReturn(booking);
when(completionCommands.insert(any())).thenReturn(1); when(completionCommands.insert(any())).thenReturn(1);
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true); 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.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
when(repository.inboundOwners(anyLong())).thenReturn(List.of( when(repository.inboundOwners(anyLong())).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L))); new LoadingPickItemMapper.InboundOwner(101L, 50L)));
@@ -931,6 +938,7 @@ class LoadingPickServiceTest {
assertThat(replay.taskStatus()).isEqualTo("COMPLETING"); assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
verify(repository, never()).taskForUpdate(20L); verify(repository, never()).taskForUpdate(20L);
verify(completionCommands, times(1)).insert(any()); verify(completionCommands, times(1)).insert(any());
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class).count()).isEqualTo(1);
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
} }
@@ -997,6 +1005,8 @@ class LoadingPickServiceTest {
assertThat(result.workflowStatus()).isEqualTo("PROCESSING"); assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
verify(completionCommands).resume(902L); verify(completionCommands).resume(902L);
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class)
.map(LoadingPickService.CompletionAccepted::commandId)).containsExactly(902L);
verify(repository).resumeCompletion(20L); verify(repository).resumeCompletion(20L);
verify(completionCommands, never()).insert(any()); verify(completionCommands, never()).insert(any());
} }
@@ -1089,7 +1099,7 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), 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), 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(loadingListService).publishCompletedEvent(10L);
verify(completionCommands).complete(931L, "worker-completed-checkpoints"); verify(completionCommands).complete(931L, "worker-completed-checkpoints");
} }
@@ -1109,6 +1119,31 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).publishCompletedEvent(anyLong()); 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 @Test
void anEntirelySkippedTaskCannotCreateACompletionCommand() { void anEntirelySkippedTaskCannotCreateACompletionCommand() {
when(repository.taskForUpdate(20L)).thenReturn(task()); when(repository.taskForUpdate(20L)).thenReturn(task());
@@ -1271,8 +1306,8 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString()); anyString(), anyString(), anyString());
verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY"); verify(completionCommands).checkpoint(904L, "worker-failure", 101L, 50L, 1, "READY");
verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(), verify(completionCommands, never()).checkpoint(eq(904L), anyString(), anyLong(), anyLong(), anyInt(),
eq("SETTLED")); eq("SETTLED"));
verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
@@ -1300,7 +1335,7 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString()); 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(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong()); verify(loadingListService, never()).publishCompletedEvent(anyLong());
@@ -46,6 +46,8 @@ class ContainerCalendarSourceMapperTest {
var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1)); 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)); var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L));
assertEquals(2, calendar.size()); assertEquals(2, calendar.size());
var explicit = session.getMapper(ContainerCalendarSourceMapper.class).byIds(java.util.List.of(1L,2L));
assertEquals(calendar,explicit);
assertEquals(2, workbench.size()); assertEquals(2, workbench.size());
for (int index = 0; index < calendar.size(); index++) { for (int index = 0; index < calendar.size(); index++) {
var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00"); var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00");
+3 -3
View File
@@ -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`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 内部模块化基线与依赖分析 | `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/编码/名称一致 | | 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `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` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | | 装柜完成事件消费 | `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` | | 订单查询与更新 | `service/LastlegOrderService.java` |
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
@@ -112,7 +112,7 @@
## 事件建单规则 ## 事件建单规则
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 - 只接受冻结契约支持的事件类型和版本;校验事件 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 分配,不得覆盖来源托盘快照。 - `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 - 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
@@ -11,7 +11,7 @@ public interface AppointmentForecastClient {
record Request(List<Long> containerBookingIds) { } record Request(List<Long> containerBookingIds) { }
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference, record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { } 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") @PostMapping("/internal/firstleg/appointment-forecasts/batch")
ApiResponse<List<Forecast>> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request); ApiResponse<List<Forecast>> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request);
} }
@@ -12,24 +12,31 @@ public interface WarehouseCalendarMapper {
@Select(""" @Select("""
<script> <script>
SELECT * FROM ( SELECT * FROM (
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo, SELECT o.source_container_booking_id sourceContainerBookingId,
MAX(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo')),'null')) cabinetNo,
MAX(o.container_no) containerNo,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName, CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode, CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p AND MAX(o.arrival_time_version)>0
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id))
THEN MAX(o.estimated_warehouse_at) END plannedAt, THEN MAX(o.estimated_warehouse_at) END plannedAt,
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s (SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt, WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version, CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id (SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount, WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
FALSE canEdit FALSE canEdit,
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL (SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1
THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) plannedDate,
FALSE isDefault
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
WHERE o.source_container_booking_id IS NOT NULL
GROUP BY o.source_container_booking_id GROUP BY o.source_container_booking_id
) c WHERE ) c WHERE
(c.plannedAt &gt;= #{from} AND c.plannedAt &lt; #{to}) (c.plannedAt &gt;= #{from} AND c.plannedAt &lt; #{to})
OR (c.plannedDate &gt;= DATE(#{from}) AND c.plannedDate &lt; DATE(#{to}))
<if test="ids != null and !ids.isEmpty()"> <if test="ids != null and !ids.isEmpty()">
OR c.sourceContainerBookingId IN OR c.sourceContainerBookingId IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
@@ -14,7 +14,7 @@ public final class WarehouseCalendarModels {
private WarehouseCalendarModels() { } private WarehouseCalendarModels() { }
public record Query(@NotNull LocalDate from, @NotNull LocalDate to, public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { } @NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName, public record Entry(String sourceContainerBookingId, String cabinetNo, String containerNo, String warehouseName,
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version, String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
long planCount, boolean canEdit) { } long planCount, boolean canEdit, LocalDate plannedDate, boolean isDefault) { }
} }
@@ -10,8 +10,10 @@ import java.util.List;
@Service @Service
public class WarehouseCalendarService { public class WarehouseCalendarService {
private final WarehouseCalendarRepository repository; private final WarehouseCalendarRepository repository;
private final com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals;
/** 装配日历只读仓储查询。 */ /** 装配日历只读仓储查询。 */
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; } public WarehouseCalendarService(WarehouseCalendarRepository repository,
com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals) { this.repository = repository; this.arrivals=arrivals; }
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */ /** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) { public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from()) if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
@@ -20,8 +22,16 @@ public class WarehouseCalendarService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
var rows = repository.calendar(query); var rows = repository.calendar(query);
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围"); if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(), var resolved=arrivals.resolve(rows.stream().map(WarehouseCalendarModels.Entry::sourceContainerBookingId).toList())
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(), .stream().collect(java.util.stream.Collectors.toMap(
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList(); com.baiyue.lastleg.dto.ContainerArrivalTimeView::sourceContainerBookingId,java.util.function.Function.identity()));
return rows.stream().map(row -> {
var arrival=resolved.get(row.sourceContainerBookingId());
if(arrival==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到仓日期读取不完整");
return new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.cabinetNo(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null,
arrival.plannedDate(),arrival.isDefault());
}).toList();
} }
} }
@@ -30,7 +30,7 @@ public final class DispatchCommands {
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, Instant plannedDepartureAt, Instant estimatedArrivalAt,
String remark, Boolean publishLooseboardDemand) { String remark, Boolean publishLooseboardDemand, Maintenance maintenance) {
/** 缺版本与有效版本零必须区分。 */ /** 缺版本与有效版本零必须区分。 */
public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); } public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
} }
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.dispatch.port; package com.baiyue.lastleg.dispatch.port;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@@ -42,7 +44,37 @@ public final class DispatchContracts {
public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone, public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone,
DriverEvidence evidence) { } DriverEvidence evidence) { }
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { } /** 本趟可空维护资料,不替代正式预约或实际发车事实。 */
public record Maintenance(@JsonFormat(shape = JsonFormat.Shape.STRING)
@JsonDeserialize(using = DispatchMaintenanceDateDeserializer.class)
LocalDate plannedDeliveryDate, String deliveryMethod, String pickupMethod,
String loadType, String fleetEmail, String vehicleType,
String appointmentAccount, String appointmentNo) {
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
public Maintenance {
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
pickupMethod = option(pickupMethod, "提卸方式", "一提一卸", "一提多卸");
loadType = option(loadType, "装载类型", "托盘", "地板", "混装");
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
appointmentNo = limited(appointmentNo, "本趟预约号", 128);
}
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
private static String option(String value, String label, String... allowed) {
String normalized = limited(value, label, 128);
if (normalized != null && !List.of(allowed).contains(normalized)) throw new IllegalArgumentException(label + "无效");
return normalized;
}
/** 统一文本空值及长度边界,不把补充资料写入备注或车牌。 */
private static String limited(String value, String label, int max) {
String normalized = value == null || value.isBlank() ? null : value.trim();
if (normalized != null && normalized.length() > max) throw new IllegalArgumentException(label + "最多" + max + "字符");
return normalized;
}
}
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record Destination(String key, String type, String warehouseId, String warehouseCode, public record Destination(String key, String type, String warehouseId, String warehouseCode,
String warehouseName, String address, String postalCode, String warehouseName, String address, String postalCode,
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.port;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
/** 本趟补充日期允许清空,但不能将时间串或不存在的日期截断、修正为有效日期。 */
public final class DispatchMaintenanceDateDeserializer extends StdDeserializer<LocalDate> {
/** 仅绑定维护资料的可空日期,不改变其他业务日期解析规则。 */
public DispatchMaintenanceDateDeserializer() { super(LocalDate.class); }
/** 空白归空,其余只接受四位年、两位月日的严格ISO日期。 */
@Override
public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException {
if (!parser.hasToken(JsonToken.VALUE_STRING)) return (LocalDate) context.handleUnexpectedToken(LocalDate.class, parser);
String value = parser.getText().trim();
if (value.isEmpty()) return null;
try {
if (!value.matches("\\d{4}-\\d{2}-\\d{2}")) throw new DateTimeParseException("日期格式无效", value, 0);
return LocalDate.parse(value);
} catch (DateTimeParseException failure) {
return (LocalDate) context.handleWeirdStringValue(LocalDate.class, value, "计划派送日期须为有效的YYYY-MM-DD");
}
}
}
@@ -36,7 +36,7 @@ public final class DispatchViews {
boolean pickingReconciled, boolean arranged, boolean pickingReconciled, boolean arranged,
Map<Action, Gate> actions) { } Map<Action, Gate> actions) { }
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { } public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo, public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
String destinationKey, String destinationWarehouseCode, String destinationKey, String destinationWarehouseCode,
@@ -310,7 +310,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0))); row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds(); var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos); if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
Resources resources=resources(shell,photos); row.setResourcesJson(write(resources)); Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate()); row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt())); row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0); row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
@@ -379,7 +379,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return saveCommand(row,actor,identity,request.reason()); return saveCommand(row,actor,identity,request.reason());
} }
/** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */ /** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
@Transactional(isolation=Isolation.READ_COMMITTED) @Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) { public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
requirePlatform(actor); requirePlatform(actor);
@@ -391,8 +391,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
// 本趟司机照片随资源维护保存,逐个核对本车及照片类别。 // 本趟司机照片随资源维护保存,逐个核对本车及照片类别。
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds(); var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos); if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
var vehiclePhotos=request.vehicle()==null?null:request.vehicle().evidenceFileIds();
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setResourcesJson(write(resources(actual,photos,request.maintenance())));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
row.setPublishLooseboardDemand(request.publishLooseboardDemand()); row.setPublishLooseboardDemand(request.publishLooseboardDemand());
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
@@ -675,12 +677,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积"); var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间"); var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间");
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期"); if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds()); if(v.mode()==ResourceMode.EXTERNAL || v.evidenceFileIds()!=null && !v.evidenceFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds()); if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds()); if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()), var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
} }
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */ /** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
void requireReviewAppointment(DispatchAggregate row) { void requireReviewAppointment(DispatchAggregate row) {
@@ -763,6 +765,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
} }
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */ /** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
/** 四页签读取同一资源快照,维护资料和本趟车辆照片不回退为主档或备注。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Detail detail(Actor actor, String number) { public DispatchViews.Detail detail(Actor actor, String number) {
var row=required(actor,number,false); var cargo=repository.cargo(row.getId()); var row=required(actor,number,false); var cargo=repository.cargo(row.getId());
@@ -778,7 +781,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
d.status(),d.packageCount(),d.palletCount())).toList(); d.status(),d.packageCount(),d.palletCount())).toList();
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver(),resources.maintenance()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand())); documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand()));
} }
@@ -1000,12 +1003,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return List.copyOf(result); return List.copyOf(result);
} }
/** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */ /** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos) { private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos,Maintenance maintenance) {
var v=shell.vehicle(); var d=shell.driver(); var v=shell.vehicle(); var d=shell.driver();
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(), return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(), new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos))); new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)),maintenance);
} }
/** 主档选择不接受请求覆盖其车牌和容量。 */ /** 主档选择不接受请求覆盖其车牌和容量。 */
@@ -25,7 +25,7 @@ public class TransportReviewPhotos {
} }
/** 读取已保存资源;空排车还没有照片。 */ /** 读取已保存资源;空排车还没有照片。 */
public Resources resources(DispatchAggregate row) { public Resources resources(DispatchAggregate row) {
if(row.getResourcesJson()==null)return new Resources(null,null); if(row.getResourcesJson()==null)return new Resources(null,null,null);
try{return json.readValue(row.getResourcesJson(),Resources.class);} try{return json.readValue(row.getResourcesJson(),Resources.class);}
catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);} catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);}
} }
@@ -109,21 +109,21 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key); history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key);
history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history); history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history);
} }
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */ /** 主单展示真实主档分配,并保留本趟维护资料及同一资源的照片。 */
private void resources(DispatchAggregate row,FleetTripEntity trip) { private void resources(DispatchAggregate row,FleetTripEntity trip) {
var old=readResources(row); var old=readResources(row);
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources( row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()), trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),
old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()), old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()),
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(), trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null)))); old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null),old.maintenance())));
} }
/** 照片变更单独纳入主单版本和审核依据,资源分配失败时由外层事务一起回滚。 */ /** 照片变更纳入主单版本和审核依据,保留原维护资料,失败由外层事务一起回滚。 */
public void savePhotos(String number,String operator,List<Long> vehiclePhotos,DriverEvidence driverPhotos) { public void savePhotos(String number,String operator,List<Long> vehiclePhotos,DriverEvidence driverPhotos) {
var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver(); var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver();
if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配"); if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配");
var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos), var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos),
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos)); new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos),old.maintenance());
if(next.equals(old))return; if(next.equals(old))return;
long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1); long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1);
row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING"); row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
@@ -134,7 +134,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
} }
/** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */ /** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */
private Resources readResources(DispatchAggregate row) { private Resources readResources(DispatchAggregate row) {
if(row.getResourcesJson()==null)return new Resources(null,null); if(row.getResourcesJson()==null)return new Resources(null,null,null);
try{return json.readValue(row.getResourcesJson(),Resources.class);} try{return json.readValue(row.getResourcesJson(),Resources.class);}
catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);} catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);}
} }
@@ -8,5 +8,7 @@ public record ContainerArrivalTimeView(
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
int version, int version,
long planCount long planCount,
java.time.LocalDate plannedDate,
boolean isDefault
) { } ) { }
@@ -301,7 +301,7 @@ public class FleetTripService {
return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString());
} }
/** 已持父级全部身份锁后,复验资源实际资格;快照不信任请求中的主档名称和容量。 */ /** 复验主档资格与名称容量;本趟已由排车核验归属的车辆照片保持独立,不被主档重建清空。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle, public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle,
FleetExecutionModels.DispatchDriver driver) { FleetExecutionModels.DispatchDriver driver) {
@@ -315,7 +315,7 @@ public class FleetTripService {
var assignment=repository.resource("VEHICLE",actual.id()); var assignment=repository.resource("VEHICLE",actual.id());
if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用"); if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用");
vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(), vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(),
actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),List.of());vehicleStatus=actual.status(); actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),vehicle.evidenceFileIds()==null?List.of():List.copyOf(vehicle.evidenceFileIds()));vehicleStatus=actual.status();
} else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案"); } else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案");
if(driver!=null && "MASTER".equals(driver.mode())) { if(driver!=null && "MASTER".equals(driver.mode())) {
if(driver.driverId()==null)throw bad("请选择司机档案"); if(driver.driverId()==null)throw bad("请选择司机档案");
@@ -79,11 +79,6 @@ public interface InboundPlanOrderMapper {
String findSourceContainerBookingId(@Param("customerId") String customerId, String findSourceContainerBookingId(@Param("customerId") String customerId,
@Param("orderId") Long orderId); @Param("orderId") Long orderId);
@Select("SELECT estimated_warehouse_at FROM ll_order " +
"WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id")
List<LocalDateTime> containerEstimatedWarehouseTimes(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") @Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findOrderType(@Param("customerId") String customerId, String findOrderType(@Param("customerId") String customerId,
@Param("orderId") Long orderId); @Param("orderId") Long orderId);
@@ -69,6 +69,7 @@ public class InboundPlanService {
@Resource private LastlegOrderCommandRecordMapper commandMapper; @Resource private LastlegOrderCommandRecordMapper commandMapper;
@Resource private LastlegSourcePalletMapper sourcePalletMapper; @Resource private LastlegSourcePalletMapper sourcePalletMapper;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival;
public List<String> sourcePalletNos(String customerId, long orderId, String orderType) { public List<String> sourcePalletNos(String customerId, long orderId, String orderType) {
return "ONE_CLICK_DROPSHIP".equals(orderType) return "ONE_CLICK_DROPSHIP".equals(orderType)
@@ -775,20 +776,13 @@ public class InboundPlanService {
} }
} }
/** 计划日期使用与清关和预约日历相同的权威解析,不以约仓时间代替。 */
private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) { private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId); String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) { if (bookingId == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间"); throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间");
} }
List<LocalDateTime> values = orderMapper.containerEstimatedWarehouseTimes(bookingId); return warehouseArrival.requiredDate(bookingId);
if (values == null || values.isEmpty() || values.stream().anyMatch(Objects::isNull)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间缺失,请先补齐柜级时间");
}
LocalDateTime expected = values.get(0);
if (values.stream().anyMatch(value -> !Objects.equals(expected, value))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间不一致,请先修正柜级时间");
}
return expected.toLocalDate();
} }
private void requirePlanContainer(InboundPlan plan, String containerNo) { private void requirePlanContainer(InboundPlan plan, String containerNo) {
@@ -210,19 +210,26 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") @Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
/** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */ /** 仅查询已冻结来源柜的头程交接尾程号,调用方先取得柜级互斥锁。 */
@Select("SELECT DISTINCT order_no FROM ll_order WHERE source_container_booking_id=#{bookingId} " +
"AND source_region='FIRSTLEG' ORDER BY order_no")
List<String> containerOrderNumbers(@Param("bookingId") String bookingId);
/** 人工时间与计划冻结日期分别返回;默认日期由共用业务解析器读取权威到港后生成。 */
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," + @Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " + "CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
"AND (MAX(o.arrival_time_version)&gt;0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p " + "AND MAX(o.arrival_time_version)&gt;0 " +
"JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id)) " +
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," + "THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " + "(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," + "WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," + "CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," +
"(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " + "(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " +
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " + "ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount " + "WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount," +
"(SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1 " +
"THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p JOIN ll_order po " +
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS plannedDate,FALSE AS isDefault " +
"FROM ll_order o WHERE o.source_container_booking_id IN " + "FROM ll_order o WHERE o.source_container_booking_id IN " +
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " + "<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY o.source_container_booking_id</script>") "GROUP BY o.source_container_booking_id</script>")
@@ -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<ContainerArrivalTimeView> find(List<String> ids) { return mapper.findContainerArrivalTimes(ids); }
}
@@ -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<String> values = mapper.containerOrderNumbers(bookingId);
if (values.size() > 1) throw new IllegalStateException("同一来源柜存在多个尾程业务编号");
return values.isEmpty() ? null : values.get(0);
}
}
@@ -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<ContainerArrivalTimeView> resolve(List<String> 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<Long,LocalDate> defaults=new HashMap<>();
for(int start=0;start<pending.size();start+=200) {
var batch=pending.subList(start,Math.min(start+200,pending.size()));
var response=forecasts.batch(token,new AppointmentForecastClient.Request(batch));
if(response==null || !response.successful() || response.data()==null || response.data().size()!=batch.size())
throw unavailable();
var seen=new java.util.HashSet<Long>();
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,"来源柜到港日期暂不可读取,请稍后重试");
}
}
@@ -10,7 +10,7 @@ import java.util.Set;
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ /** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
@Component @Component
public class LastlegDocumentNumberGenerator { public class LastlegDocumentNumberGenerator {
private static final Set<String> 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<String> 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 DocumentSequenceRepository sequences;
private final Clock clock; private final Clock clock;
@@ -1,14 +1,9 @@
package com.baiyue.lastleg.service; package com.baiyue.lastleg.service;
import org.springframework.stereotype.Component; 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; import java.util.Locale;
/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */ /** 海外单据类型映射;业务单号统一使用本服务日流水。 */
@Component @Component
public class LastlegOrderNoGenerator { public class LastlegOrderNoGenerator {
private final LastlegDocumentNumberGenerator documents; private final LastlegDocumentNumberGenerator documents;
@@ -21,6 +16,7 @@ public class LastlegOrderNoGenerator {
/** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */ /** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */
public String next(String businessType) { public String next(String businessType) {
String prefix = switch (businessType) { String prefix = switch (businessType) {
case "LASTLEG" -> "WC";
case "ONE_CLICK_DROPSHIP" -> "DFCK"; case "ONE_CLICK_DROPSHIP" -> "DFCK";
case "OUTBOUND" -> "CK"; case "OUTBOUND" -> "CK";
case "RELABEL_OUTBOUND" -> "HBCK"; case "RELABEL_OUTBOUND" -> "HBCK";
@@ -45,16 +41,4 @@ public class LastlegOrderNoGenerator {
return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT); 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);
}
}
} }
@@ -83,6 +83,7 @@ public class LastlegOrderService {
@Resource private InventoryCommandService inventory; @Resource private InventoryCommandService inventory;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Resource private ContainerWarehouseArrivalService warehouseArrival;
@Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private WarehouseResourceGateway warehouseResourceGateway;
@Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ContainerShippingGateMapper containerShippingGates;
@Resource private ReturnOrderReferenceResolver returnOrderReferences; @Resource private ReturnOrderReferenceResolver returnOrderReferences;
@@ -142,6 +143,7 @@ public class LastlegOrderService {
orderMapper.findContainerProgressItems(bookingId)); orderMapper.findContainerProgressItems(bookingId));
} }
/** 清关列表读取统一解析的柜级日期,默认推算不写入订单。 */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ContainerArrivalTimeView> containerArrivalTimes( public List<ContainerArrivalTimeView> containerArrivalTimes(
LastlegOrderRequests.ContainerArrivalTimeQuery request) { LastlegOrderRequests.ContainerArrivalTimeQuery request) {
@@ -150,7 +152,7 @@ public class LastlegOrderService {
if (bookingIds.size() != request.sourceContainerBookingIds().size()) { if (bookingIds.size() != request.sourceContainerBookingIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复");
} }
return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds)); return warehouseArrival.resolve(List.copyOf(bookingIds));
} }
@Transactional @Transactional
@@ -194,8 +196,9 @@ public class LastlegOrderService {
return requireContainerArrivalTime(bookingId); return requireContainerArrivalTime(bookingId);
} }
/** 保存后沿同一读取入口返回人工日期、版本和计划冻结事实。 */
private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) { private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) {
List<ContainerArrivalTimeView> values = orderMapper.findContainerArrivalTimes(List.of(bookingId)); List<ContainerArrivalTimeView> values = warehouseArrival.resolve(List.of(bookingId));
if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
if (values.get(0).version() < 0) { if (values.get(0).version() < 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致");
@@ -44,12 +44,14 @@ public class LoadingCompletedConsumer {
@Resource private InboundPlanCartonMapper cartonMapper; @Resource private InboundPlanCartonMapper cartonMapper;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private com.baiyue.lastleg.repository.LastlegOrderNumberRepository orderNumbers;
@Resource private DropshipOrderService dropshipOrders; @Resource private DropshipOrderService dropshipOrders;
@Resource private InventoryCommandService inventory; @Resource private InventoryCommandService inventory;
@Resource private ContainerShippingGateMapper shippingGates; @Resource private ContainerShippingGateMapper shippingGates;
@Resource private LastlegSourcePalletMapper sourcePallets; @Resource private LastlegSourcePalletMapper sourcePallets;
@Resource private ContainerLoadingEvidenceMapper loadingEvidence; @Resource private ContainerLoadingEvidenceMapper loadingEvidence;
/** 校验交接身份并在柜级锁内为各客户的冻结入仓单建立同柜尾程记录。 */
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public DeliveryAck consume(EventEnvelope envelope) { public DeliveryAck consume(EventEnvelope envelope) {
envelope.requireValid(); envelope.requireValid();
@@ -94,7 +96,9 @@ public class LoadingCompletedConsumer {
// Seeding the shipping version from the loading-plan version makes a valid first node // 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. // (aggregateVersion=0) look stale whenever the loading plan has a higher version.
JsonNode rawOrders = envelope.payload().path("orders"); 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++) { for (int index = 0; index < payload.orders().size(); index++) {
var source = payload.orders().get(index); var source = payload.orders().get(index);
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
@@ -8,8 +8,8 @@ paths:
description: >- description: >-
仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。 返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。
同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0或已经建计划时才有值。 同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0时才有值;仅日期的冻结计划通过plannedDate返回。
未人工维护、未建计划的日历默认日期由到港来源加2个自然日;此只读接口不写入库计划、不补造时刻。 plannedDate/isDefault由与清关提柜和入库计划共用的服务端解析器返回,默认按权威到港当地日期加2个自然日;此只读接口不写入库计划、不补造时刻。
actualAt只读PDA首次收货时间,不替换计划日期。 actualAt只读PDA首次收货时间,不替换计划日期。
具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。 具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。
保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。 保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。
@@ -41,10 +41,13 @@ paths:
type: object type: object
properties: properties:
sourceContainerBookingId: {type: string} sourceContainerBookingId: {type: string}
cabinetNo: {type: string, nullable: true, description: 来源交接冻结的柜次号,作为日历主标识}
containerNo: {type: string, nullable: true} containerNo: {type: string, nullable: true}
warehouseName: {type: string, nullable: true} warehouseName: {type: string, nullable: true}
warehouseCode: {type: string, nullable: true} warehouseCode: {type: string, nullable: true}
plannedAt: {type: string, format: date-time, 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} actualAt: {type: string, format: date-time, nullable: true}
version: {type: integer, minimum: -1} version: {type: integer, minimum: -1}
planCount: {type: integer, minimum: 0} planCount: {type: integer, minimum: 0}
@@ -3582,7 +3582,9 @@ components:
required: [sourceContainerBookingId, version, planCount] required: [sourceContainerBookingId, version, planCount]
properties: properties:
sourceContainerBookingId: { type: string } 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 首次开始收货时间 } actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 }
version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 } version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 }
planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 } planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 }
@@ -4022,6 +4024,11 @@ components:
remark: remark:
type: string type: string
nullable: true nullable: true
maintenance:
allOf:
- $ref: '#/components/schemas/DispatchMaintenance'
nullable: true
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
required: required:
- publishLooseboardDemand - publishLooseboardDemand
- recordVersion - recordVersion
@@ -4558,7 +4565,7 @@ components:
items: { $ref: '#/components/schemas/LastlegOrderManualNode' } items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
ManualInboundPlanRequest: ManualInboundPlanRequest:
type: object type: object
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。 description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由共用服务端解析器返回 plannedDate:人工时间或已有计划冻结日期优先,其余按 Firstleg 权威到港当地日期加两个自然日;同柜所有计划保持一致,不接受人工提交,不补造默认时间。
required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent] required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent]
properties: properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' } customerId: { type: string, pattern: '^[1-9][0-9]*$' }
@@ -5390,6 +5397,43 @@ components:
- name - name
- phone - phone
- evidence - 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: DispatchResources:
type: object type: object
x-java-record: DispatchContracts.Resources x-java-record: DispatchContracts.Resources
@@ -5402,6 +5446,11 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/DispatchDriverSnapshot' - $ref: '#/components/schemas/DispatchDriverSnapshot'
nullable: true nullable: true
maintenance:
allOf:
- $ref: '#/components/schemas/DispatchMaintenance'
nullable: true
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
required: required:
- vehicle - vehicle
- driver - driver
@@ -6356,6 +6405,11 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/DispatchDriverSnapshot' - $ref: '#/components/schemas/DispatchDriverSnapshot'
nullable: true nullable: true
maintenance:
allOf:
- $ref: '#/components/schemas/DispatchMaintenance'
nullable: true
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
required: required:
- vehicle - vehicle
- driver - driver
@@ -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); 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) { 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) { static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) {
return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null); return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null);
@@ -11,11 +11,21 @@ import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import static org.junit.jupiter.api.Assertions.*; 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 { @Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1"); 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()) { 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," + 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," + "overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," +
"estimated_warehouse_at TIMESTAMP,arrival_time_version INT)"); "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)," + "(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)"); "(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("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)); var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(WarehouseCalendarMapper.class); 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")); 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(3,rows.size());
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt()); 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(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")); var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3"));
assertEquals(3,customs.size()); assertEquals(3,customs.size());
for(var calendar:rows) { for(var calendar:rows) {
@@ -10,10 +10,15 @@ import static org.mockito.Mockito.*;
class WarehouseCalendarServiceTest { class WarehouseCalendarServiceTest {
/** 读取资格不代替写权限,已有入库计划仍冻结时间。 */ /** 读取资格不代替写权限,已有入库计划仍冻结时间。 */
@Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() { @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 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 open=new WarehouseCalendarModels.Entry("1","D0001","CONT","Warehouse","W",null,null,0,0,false,null,false);
var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,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)); 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,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit());
assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit)); assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit));
@@ -93,7 +93,7 @@ public class DispatchAggregatePersistenceTest {
var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class); var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class);
var d=resources.driver();var proof=d.evidence(); 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(), 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); 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 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); 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<Long> ids) throws Exception { String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of()))); 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", 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(); f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
} }
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */ /** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
@@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest {
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { @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 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)); 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.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
@@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest {
} }
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { @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 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 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 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(); var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
@@ -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(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))"); 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 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()); service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper());
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
} }
@@ -17,7 +17,7 @@ class DispatchCapacityDemandPersistenceTest {
var service=new DispatchCapacityDemandService(f.repository,f.json); var service=new DispatchCapacityDemandService(f.repository,f.json);
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo()); assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue(); 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)); 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(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
assertThat(service.page(query()).total()).isZero(); assertThat(service.page(query()).total()).isZero();
@@ -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<Long> 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");
}
}
@@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest {
var e = d.evidence(); var e = d.evidence();
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), 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)); 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);
} }
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
@@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest {
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); 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 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 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 detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
@@ -55,13 +55,13 @@ class TransportReviewPhotosTest {
/** 换司机后旧人像不允许重复绑定。 */ /** 换司机后旧人像不允许重复绑定。 */
@Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception { @Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception {
row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000", 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("重新上传"); assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传");
} }
/** 审核复验已保存证据,照片后续失效也不能通过。 */ /** 审核复验已保存证据,照片后续失效也不能通过。 */
@Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception { @Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception {
row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)), 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); service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L);
when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效")); when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效"));
assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效"); assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效");
@@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest {
when(reader.detail(any(),anyString())).thenAnswer(call->{ 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); 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(), 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)); queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec)); var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
@@ -67,7 +67,7 @@ class OwnedBusinessScopePersistenceTest {
var original=OwnedBusinessFixtures.source(); var original=OwnedBusinessFixtures.source();
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class); 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()); 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)); String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
baseline.jdbc.update(""" 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, INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
@@ -58,12 +58,14 @@ class InboundPlanServiceTest {
@Mock WarehouseResourceGateway warehouseGateway; @Mock WarehouseResourceGateway warehouseGateway;
@Mock LastlegOrderCommandRecordMapper commandMapper; @Mock LastlegOrderCommandRecordMapper commandMapper;
@Mock LastlegSourcePalletMapper sourcePalletMapper; @Mock LastlegSourcePalletMapper sourcePalletMapper;
@Mock com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival;
private InboundPlanService service; private InboundPlanService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new InboundPlanService(); service = new InboundPlanService();
ReflectionTestUtils.setField(service,"warehouseArrival",warehouseArrival);
ReflectionTestUtils.setField(service, "planMapper", planMapper); ReflectionTestUtils.setField(service, "planMapper", planMapper);
ReflectionTestUtils.setField(service, "lineMapper", lineMapper); ReflectionTestUtils.setField(service, "lineMapper", lineMapper);
ReflectionTestUtils.setField(service, "palletMapper", palletMapper); ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
@@ -95,8 +97,8 @@ class InboundPlanServiceTest {
"100", 2L, "BOOKING-1", "193000000000200003"))); "100", 2L, "BOOKING-1", "193000000000200003")));
org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong())) org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong()))
.thenReturn("BOOKING-1"); .thenReturn("BOOKING-1");
org.mockito.Mockito.lenient().when(orderMapper.containerEstimatedWarehouseTimes(any())) org.mockito.Mockito.lenient().when(warehouseArrival.requiredDate(any()))
.thenReturn(List.of(LocalDateTime.of(2026, 9, 15, 8, 0))); .thenReturn(java.time.LocalDate.of(2026, 9, 15));
} }
@Test @Test
@@ -176,15 +178,16 @@ class InboundPlanServiceTest {
void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() { void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() {
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
2, "STANDARD", true, true, true, "优先收货"); 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, ResponseStatusException missing = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-missing-arrival", "900")); () -> service.convertManual(request, "manual-missing-arrival", "900"));
assertEquals(409, missing.getStatusCode().value()); assertEquals(409, missing.getStatusCode().value());
assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason()); assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason());
when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of( org.mockito.Mockito.doThrow(new ResponseStatusException(
LocalDateTime.of(2026, 9, 15, 8, 0), LocalDateTime.of(2026, 9, 16, 8, 0))); org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间不一致,请先修正柜级时间"))
.when(warehouseArrival).requiredDate("BOOKING-1");
ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class, ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-inconsistent-arrival", "900")); () -> service.convertManual(request, "manual-inconsistent-arrival", "900"));
assertEquals(409, inconsistent.getStatusCode().value()); assertEquals(409, inconsistent.getStatusCode().value());
@@ -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);
}
}
@@ -8,7 +8,7 @@ class LastlegOrderNoGeneratorTest {
/** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */ /** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */
@Test void routesEveryOperationalTypeToItsDailySequence() { @Test void routesEveryOperationalTypeToItsDailySequence() {
var generator=TestDocumentNumbers.orders(); 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("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"),
Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"), 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"), 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")); 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(" "));
}
} }
@@ -117,7 +117,7 @@ class LastlegOrderServiceTest {
void setUp() { void setUp() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class); 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, "transferQuantities", transferQuantities);
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences); ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "orderMapper", orders);
@@ -148,7 +148,7 @@ class LastlegOrderServiceTest {
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L); when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2); when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of( 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", ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1",
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin"); new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");
@@ -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.*; 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) @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest { 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<LastlegOrder> 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<LastlegOrder> 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<String,String> frozenContainerNos=new HashMap<>(); private final Map<String,String> 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; @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; }); } @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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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 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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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<LastlegOrder> 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); } @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<LastlegOrder> 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 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<LastlegOrder> 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 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<LastlegOrder> 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); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); }