Compare commits

...
38 changed files with 569 additions and 153 deletions
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
@Autowired(required = false) @Autowired(required = false)
private MeterRegistry meterRegistry; private MeterRegistry meterRegistry;
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
@ExceptionHandler(BizException.class) @ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
if (exception.getResultCode().httpStatus().is5xxServerError()) {
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
}
return failure(exception.getResultCode(), exception.getMessage(), request); return failure(exception.getResultCode(), exception.getMessage(), request);
} }
@@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.core.MethodParameter; import org.springframework.core.MethodParameter;
import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.BeanPropertyBindingResult;
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().requestId()).isEqualTo("r1"); assertThat(response.getBody().requestId()).isEqualTo("r1");
} }
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
@Test
@ExtendWith(OutputCaptureExtension.class)
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
request.setQueryString("keyword=private-query");
request.addHeader("X-Internal-Service-Token", "private-service-token");
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
exception.initCause(new IllegalStateException("缺少尾程来源"));
var response = handler.handleBusiness(exception, request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo("S0503");
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
.doesNotContain("private-query", "private-service-token");
}
/** 预期的业务冲突不应升级为系统错误日志。 */
@Test
@ExtendWith(OutputCaptureExtension.class)
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
new MockHttpServletRequest());
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
}
@Test @Test
void invalidQueryParameterTypeMapsToBadRequest() throws Exception { void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency, BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, String sourceStatus, String reviewStatus, String sourceStatus,
boolean statementGenerated) {} boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate, public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo, BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, String createdByName, List<String> voucherFileIds, long createdBy, String createdByName,
LocalDateTime createdAt, LocalDateTime createdAt,
@@ -0,0 +1,43 @@
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;
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
@Mapper
public interface FinanceSettlementDetailMapper {
String SUMMARY_SELECT =
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
/** 读取平台结算主单及来源摘要。 */
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
/** 读取该客户该结算的费用明细。 */
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id")
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
/** 读取该客户结算修订历史。 */
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
}
@@ -0,0 +1,29 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.LinkedCaseInsensitiveMap;
import java.util.List;
import java.util.Map;
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
@Repository
public class FinanceSettlementDetailRepository {
private final FinanceSettlementDetailMapper mapper;
/** 注入结算详情持久化入口。 */
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
/** 保留原数据库列名不区分大小写的读取语义。 */
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
}
/** 读取平台结算主单及来源摘要。 */
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
/** 读取该客户该结算的费用明细。 */
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
/** 读取该客户结算修订历史。 */
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
}
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.client.FirstlegFeeSourceClient; import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import feign.FeignException; import feign.FeignException;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -437,7 +438,14 @@ public class FinancePhase1OperationService {
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; } private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } } private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } } /** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
private String hash(Object value) {
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
}
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); } private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) { private static Long jsonLong(JsonNode node, String field) {
JsonNode value = node.path(field); JsonNode value = node.path(field);
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder; import org.springframework.jdbc.support.KeyHolder;
@@ -306,7 +307,14 @@ public class FinanceService {
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation); default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
}; };
} }
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } } /** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
private String hash(Object request) {
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(request == null ? List.of() : request);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
}
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); } private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -44,23 +45,9 @@ import java.util.UUID;
public class FinanceSettlementService { public class FinanceSettlementService {
private static final String CNY = "CNY"; private static final String CNY = "CNY";
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER"); private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final String SETTLEMENT_SUMMARY_SELECT = private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator; private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator; private final FinanceSupplierValidator supplierValidator;
@@ -70,8 +57,10 @@ public class FinanceSettlementService {
@Autowired @Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator, FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers) { FinanceDocumentNumberGenerator documentNumbers,
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.details = details;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator; this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator; this.supplierValidator = supplierValidator;
@@ -182,33 +171,21 @@ public class FinanceSettlementService {
return getSettlement(settlementId); return getSettlement(settlementId);
} }
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
public SettlementDetail getSettlement(long settlementId) { public SettlementDetail getSettlement(long settlementId) {
if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
Map<String, Object> row = one( List<Map<String, Object>> records = details.summary(settlementId);
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?", if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
"结算单不存在", settlementId); SettlementSummary summary = summary(records.get(0));
SettlementSummary summary = summary(row); List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
List<SettlementLine> lines = jdbc.queryForList(
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList(); .stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList( List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " + .stream().map(this::transaction).toList());
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
summary.customerId(), settlementId).stream().map(this::transaction).toList());
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) { if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
transactions.addAll(jdbc.queryForList( transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " + .stream().map(this::transaction).toList());
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
} }
List<Map<String, Object>> audits = jdbc.queryForList( List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
summary.customerId(), settlementId);
List<SettlementRevision> revisions = new ArrayList<>(); List<SettlementRevision> revisions = new ArrayList<>();
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index))); for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions)); return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
@@ -476,8 +453,9 @@ public class FinanceSettlementService {
text(row, "status"), true); text(row, "status"), true);
} }
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
private SettlementTransaction transaction(Map<String, Object> row) { private SettlementTransaction transaction(Map<String, Object> row) {
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"), return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"), date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(), stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
text(row, "created_by_name"), text(row, "created_by_name"),
@@ -523,10 +501,13 @@ public class FinanceSettlementService {
return documentNumbers.next("PAY"); return documentNumbers.next("PAY");
} }
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
private String hash(Object value) { private String hash(Object value) {
try { try {
String canonical = objectMapper.writer()
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json(value).getBytes(StandardCharsets.UTF_8))); .digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception failure) { } catch (Exception failure) {
throw error("FIN_001", "幂等请求哈希失败"); throw error("FIN_001", "幂等请求哈希失败");
} }
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry; import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage; import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -694,8 +695,9 @@ public class FinanceWalletService {
timestamp(rs.getTimestamp("reviewed_at"))); timestamp(rs.getTimestamp("reviewed_at")));
} }
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
private String hash(Object value) { private String hash(Object value) {
try { return sha256(objectMapper.writeValueAsBytes(value)); } try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); } catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
} }
private String fileHash(MultipartFile file) { private String fileHash(MultipartFile file) {
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision; import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -521,9 +522,13 @@ public class ReconciliationStatementService {
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); } catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
} }
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
private String hash(Object value) { private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } try {
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); } byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
} }
private static LocalDateTime localTime(Object value) { private static LocalDateTime localTime(Object value) {
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class, assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
"operatorId", "operatorName"); "operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class, assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
"createdBy", "createdByName"); "createdBy", "createdByName", "transactionNo");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class, assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
"operatorId", "operatorName"); "operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class, assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of()); eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after)); when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries( when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"), Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))), Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"), Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"), Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
@@ -258,6 +258,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType()) assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("PAYMENT"); .isEqualTo("PAYMENT");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin"); assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"), verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
any(), any(), any(), any(), any(), any(), any(), any()); any(), any(), any(), any(), any(), any(), any(), any());
} }
@@ -522,6 +523,7 @@ class FinanceSettlementBehaviorTest {
.isEqualTo("RECEIPT"); .isEqualTo("RECEIPT");
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00"); assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin"); assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
} }
@Test @Test
@@ -623,8 +625,15 @@ class FinanceSettlementBehaviorTest {
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001"); org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001"); org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001"); org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001");
// 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。
var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> {
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
if (select == null) return RETURNS_DEFAULTS.answer(invocation);
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
return jdbc.queryForList(sql, invocation.getArguments());
});
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers); new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
} }
private static FinanceSupplierValidator supplierValidator() { private static FinanceSupplierValidator supplierValidator() {
@@ -657,7 +666,7 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement)); when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
long customerId = ((Number) settlement.get("tenant_id")).longValue(); long customerId = ((Number) settlement.get("tenant_id")).longValue();
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
} }
@@ -0,0 +1,36 @@
package com.baiyue.finance;
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
import com.baiyue.finance.repository.FinanceSettlementDetailRepository;
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.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementDetailMapperTest {
/** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */
@Test
void paymentNumbersRemainScopedToCustomerAndSettlement() {
var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))");
jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.addMapper(FinanceSettlementDetailMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class));
var rows = repository.payments(10, 20);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001");
assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1");
assertThat(repository.payments(11, 21)).isEmpty();
assertThat(repository.payments(10, 22)).hasSize(1);
assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull();
}
}
}
@@ -0,0 +1,47 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceSettlementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.baiyue.finance.service.FinanceWalletService;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.ReconciliationStatementService;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementRequestHashTest {
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
@ParameterizedTest
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
Map<String, Object> body = new LinkedHashMap<>();
body.put("amount", 190); body.put("expectedVersion", 0);
Map<String, Object> first = new LinkedHashMap<>();
first.put("path", "/settlements/202/transactions"); first.put("method", "POST");
first.put("customerId", 10L); first.put("body", body);
Map<String, Object> reversedBody = new LinkedHashMap<>();
reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190);
Map<String, Object> second = new LinkedHashMap<>();
second.put("body", reversedBody); second.put("customerId", 10L);
second.put("method", "POST"); second.put("path", "/settlements/202/transactions");
String original = ReflectionTestUtils.invokeMethod(service, "hash", first);
String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second);
assertThat(reordered).isEqualTo(original);
reversedBody.put("amount", 191);
String changed = ReflectionTestUtils.invokeMethod(service, "hash", second);
assertThat(changed).isNotEqualTo(original);
second.put("customerId", 11L); reversedBody.put("amount", 190);
String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second);
assertThat(otherCustomer).isNotEqualTo(original);
}
}
@@ -67,10 +67,11 @@ public class PlatformColoadGroupController {
idempotencyKey, user.userId(), user.username())); idempotencyKey, user.userId(), user.username()));
} }
/** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */
@GetMapping("/by-inbound-code/{inboundCode}") @GetMapping("/by-inbound-code/{inboundCode}")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@Operation(summary = "按入仓码查询同柜组业务摘要", @Operation(summary = "按入仓码查询同柜组业务摘要",
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。") description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。")
public ApiResponse<BusinessSummary> byInboundCode( public ApiResponse<BusinessSummary> byInboundCode(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Size(min = 1, max = 40) String inboundCode) { @PathVariable @Size(min = 1, max = 40) String inboundCode) {
@@ -71,6 +71,7 @@ public final class ColoadGroupModels {
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */ /** 页面只读业务摘要;内部组标识不作为用户识别键。 */
public record BusinessSummary( public record BusinessSummary(
String groupNo,
String status, String status,
int memberCount, int memberCount,
List<BusinessMember> members List<BusinessMember> members
@@ -108,7 +108,7 @@ public interface ColoadGroupMapper {
@Param("inboundId") long inboundId); @Param("inboundId") long inboundId);
@Select(""" @Select("""
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus
FROM fl_inbound_order requested FROM fl_inbound_order requested
JOIN fl_coload_group_member member JOIN fl_coload_group_member member
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.loading.coload.persistence;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 平台同柜组摘要的只读数据访问,保留原入仓片段和组状态范围。 */
@Repository
public class ColoadGroupQueryRepository {
private final ColoadGroupMapper mapper;
/** 注入同服务持久化入口。 */
public ColoadGroupQueryRepository(ColoadGroupMapper mapper) { this.mapper = mapper; }
/** 按业务入仓码查询当前组及其真实业务组号。 */
public List<ColoadPersistenceRows.InboundGroupLookupRow> groupsByInboundCode(String code) {
return mapper.groupsByInboundCode(code);
}
/** 读取被冻结的成员,交由服务核对缺失和重复片段。 */
public List<ColoadPersistenceRows.MemberCodeRow> effectiveMemberCodes(long groupId) {
return mapper.effectiveMemberCodes(groupId);
}
}
@@ -59,6 +59,7 @@ public final class ColoadPersistenceRows {
@Setter @Setter
public static class InboundGroupLookupRow { public static class InboundGroupLookupRow {
private Long groupId; private Long groupId;
private String groupNo;
private String groupStatus; private String groupStatus;
} }
@@ -47,6 +47,8 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
@Resource @Resource
private ColoadGroupMapper mapper; private ColoadGroupMapper mapper;
@Resource
private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository;
@Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private OrderCargoClient orderCargoClient; @Resource private OrderCargoClient orderCargoClient;
@Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -118,10 +120,11 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return requiredSnapshot(groupId); return requiredSnapshot(groupId);
} }
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public BusinessSummary queryByInboundCode(String inboundCode) { public BusinessSummary queryByInboundCode(String inboundCode) {
String code = normalizedInboundCode(inboundCode); String code = normalizedInboundCode(inboundCode);
var groups = mapper.groupsByInboundCode(code).stream() var groups = queryRepository.groupsByInboundCode(code).stream()
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus())) .filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
.toList(); .toList();
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count(); long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
@@ -131,7 +134,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组"); if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
long groupId = groups.get(0).getGroupId(); long groupId = groups.get(0).getGroupId();
String status = groups.get(0).getGroupStatus(); String status = groups.get(0).getGroupStatus();
var memberRows = mapper.effectiveMemberCodes(groupId); var memberRows = queryRepository.effectiveMemberCodes(groupId);
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段"); if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|| trim(row.getInboundCode()) == null)) { || trim(row.getInboundCode()) == null)) {
@@ -142,7 +145,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
throw conflict("同柜不可拆组存在重复入仓片段业务号"); throw conflict("同柜不可拆组存在重复入仓片段业务号");
} }
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList(); List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
return new BusinessSummary(status, members.size(), members); return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
} }
private long requiredCurrentGroupId(String inboundCode) { private long requiredCurrentGroupId(String inboundCode) {
@@ -24,7 +24,7 @@ public class WorkflowTabService {
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList()); var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
// 确认页签复用已推送费用版本。 // 确认页签复用已推送费用版本。
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) { for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
} }
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
@@ -32,19 +32,24 @@ public class WorkflowTabService {
if(!sources.isEmpty()) { if(!sources.isEmpty()) {
try { try {
var response=lastleg.read(token,new WorkflowFacts.Request(sources)); var response=lastleg.read(token,new WorkflowFacts.Request(sources));
if(response==null || !response.successful() || response.data()==null) throw unavailable(); if(response==null || !response.successful() || response.data()==null) throw unavailable(
"尾程事实响应无效 code="+(response==null?null:response.code())
+" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId()));
var allowed=new HashSet<>(sources); var allowed=new HashSet<>(sources);
for(var fact:response.data()) { for(var fact:response.data()) {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable(); if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded())); overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
} }
if(overseas.size()!=sources.size()) throw unavailable(); if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); } +" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
} catch(BizException e) { throw e; } catch(Exception e) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
}
} }
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。 // SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
for(var fact:facts) { for(var fact:facts) {
var inbound=expected.get(fact.inboundId()); var inbound=expected.get(fact.inboundId());
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable(); if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
var o=overseas.get(fact.tenantId()+":"+fact.inboundId()); var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key)); for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
} }
@@ -77,5 +82,9 @@ public class WorkflowTabService {
return keys; return keys;
} }
/** 下游缺失或归属异常不能生成部分数量。 */ /** 下游缺失或归属异常不能生成部分数量。 */
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); } private static BizException unavailable(String reason) {
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
.initCause(new IllegalStateException(reason));
}
} }
@@ -45,8 +45,8 @@ paths:
summary: 按入仓码查询同柜组业务摘要 summary: 按入仓码查询同柜组业务摘要
description: >- description: >-
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询; 需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭; 返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。 页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
parameters: parameters:
- name: inboundCode - name: inboundCode
in: path in: path
@@ -147,8 +147,9 @@ components:
ColoadGroupBusinessSummary: ColoadGroupBusinessSummary:
type: object type: object
additionalProperties: false additionalProperties: false
required: [status, memberCount, members] required: [groupNo, status, memberCount, members]
properties: properties:
groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致}
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]} status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
memberCount: {type: integer, minimum: 1} memberCount: {type: integer, minimum: 1}
members: members:
@@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest {
.doesNotContain("destinationCode") .doesNotContain("destinationCode")
.doesNotContain("expectedVersion") .doesNotContain("expectedVersion")
.doesNotContain("platform:firstleg:coload"); .doesNotContain("platform:firstleg:coload");
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members"); assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members");
assertThat(memberProperties).containsOnlyKeys("inboundCode"); assertThat(memberProperties).containsOnlyKeys("inboundCode");
} }
} }
@@ -37,7 +37,7 @@ 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;
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class}) @SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class})
class ColoadGroupServiceTest { class ColoadGroupServiceTest {
@org.springframework.context.annotation.Configuration @org.springframework.context.annotation.Configuration
static class Numbers { static class Numbers {
@@ -71,6 +71,7 @@ class ColoadGroupServiceTest {
var result = service.queryByInboundCode(" IN-A "); var result = service.queryByInboundCode(" IN-A ");
assertThat(result.groupNo()).isEqualTo("CLG202610210001");
assertThat(result.status()).isEqualTo("ACTIVE"); assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.memberCount()).isEqualTo(2); assertThat(result.memberCount()).isEqualTo(2);
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B"); assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
@@ -574,6 +575,7 @@ class ColoadGroupServiceTest {
private static InboundGroupLookupRow lookup(long groupId, String status) { private static InboundGroupLookupRow lookup(long groupId, String status) {
InboundGroupLookupRow value = new InboundGroupLookupRow(); InboundGroupLookupRow value = new InboundGroupLookupRow();
value.setGroupId(groupId); value.setGroupId(groupId);
value.setGroupNo("CLG202610210001");
value.setGroupStatus(status); value.setGroupStatus(status);
return value; return value;
} }
@@ -86,7 +86,8 @@ class ColoadGroupSqlContractTest {
.contains("requested.inbound_code=#{inboundCode}") .contains("requested.inbound_code=#{inboundCode}")
.contains("requested.deleted=0") .contains("requested.deleted=0")
.contains("'ACTIVE','LOCKED','CONSUMED'") .contains("'ACTIVE','LOCKED','CONSUMED'")
.doesNotContain("group_no", "order_id=#{"); .contains("group_row.group_no groupNo")
.doesNotContain("order_id=#{");
assertThat(memberSql) assertThat(memberSql)
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode") .contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
.contains("LEFT JOIN fl_inbound_order inbound") .contains("LEFT JOIN fl_inbound_order inbound")
@@ -25,12 +25,15 @@ class WorkflowBoundaryTest {
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(fact())); when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of())); when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class)
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(22,1,true,true,false,true)))); new WorkflowFacts.Overseas(22,1,true,true,false,true))));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable")); var failure=new IllegalStateException("unavailable");
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); when(downstream.read(any(),any())).thenThrow(failure);
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
} }
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */ /** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.trip;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
@Repository
public class FleetTripCalendarRepository {
public record Source(String dispatchNo, String sourceSnapshotJson) { }
private final FleetTripMapper mapper;
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */
public FleetTripCalendarRepository(FleetTripMapper mapper) { this.mapper = mapper; }
/** 按真实UTC时间窗口读取有唯一整车主单的运输记录。 */
public List<FleetTripCalendarRow> calendar(LocalDateTime from, LocalDateTime to) {
return mapper.calendar(from, to);
}
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */
public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
if (dispatchNumbers.isEmpty()) return List.of();
return mapper.calendarSources(dispatchNumbers).stream()
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList();
}
}
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.*; import java.time.*;
@@ -18,27 +19,29 @@ public class FleetTripCalendarService {
String originWarehouseCode, String originWarehouseName, String originCountryCode, String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<FleetTripModels.Stop> stops, List<String> incompleteReasons) { } List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { }
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { } public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries()); private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
private final FleetTripMapper trips; private final FleetTripCalendarRepository trips;
private final FleetWarehouseClient warehouses; private final FleetWarehouseClient warehouses;
private final ObjectMapper json; private final ObjectMapper json;
private final String token; private final String token;
public FleetTripCalendarService(FleetTripMapper trips, FleetWarehouseClient warehouses, ObjectMapper json, /** 装配日历持久化入口与真实仓库元数据提供者。 */
public FleetTripCalendarService(FleetTripCalendarRepository trips, FleetWarehouseClient warehouses, ObjectMapper json,
@Value("${baiyue.security.internal-service-token:}") String token) { @Value("${baiyue.security.internal-service-token:}") String token) {
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token; this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
} }
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */ /** 同一快照批读真实主单和货物,仓库资料只查询一次,保留原业务地点时区。 */
@Transactional(readOnly=true) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Result calendar(Instant from, Instant to) { public Result calendar(Instant from, Instant to) {
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0) if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC)); var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围"); if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of()); if (rows.isEmpty()) return new Result(List.of(),List.of());
var containers=containerNumbers(rows);
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList(); var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids); var metadata=metadata(ids);
var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>(); var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>();
@@ -64,12 +67,35 @@ public class FleetTripCalendarService {
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(), var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(), warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()), country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons)); instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons));
(unresolvedOrigin?unresolved:records).add(entry); (unresolvedOrigin?unresolved:records).add(entry);
} }
return new Result(List.copyOf(records),List.copyOf(unresolved)); return new Result(List.copyOf(records),List.copyOf(unresolved));
} }
/** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */
private Map<String,List<String>> containerNumbers(List<FleetTripCalendarRow> rows) {
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var source:trips.sourceSnapshots(numbers)) {
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
throw sourceUnavailable();
try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(source.sourceSnapshotJson());
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get("containerNo");
if(value==null || value.isNull())continue;
if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip();
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number);
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
}
var result=new LinkedHashMap<String,List<String>>();
grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result);
}
/** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */
private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) { private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) {
if (ids.isEmpty()) return Map.of(); if (ids.isEmpty()) return Map.of();
try { try {
@@ -85,6 +111,10 @@ public class FleetTripCalendarService {
return result; return result;
} catch (RuntimeException failure) { throw unavailable(); } } catch (RuntimeException failure) { throw unavailable(); }
} }
/** 持久UTC时间只转换为UTC瞬时,不套用浏览器日期。 */
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); } private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
/** 仓库服务失败明确报错,不能把未知时区伪装成完整空日历。 */
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); } private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); }
/** 损坏货物快照保留明确错误,不静默丢弃真实排车或柜号。 */
private static ResponseStatusException sourceUnavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"排车货物柜号资料暂不可用,请重试"); }
} }
@@ -19,6 +19,18 @@ public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001 ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""") """)
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
record CalendarSource(String dispatchNo, String sourceSnapshotJson) { }
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */
@Select("""
<script>
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
WHERE d.status='ACTIVE' AND d.dispatch_no IN
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
ORDER BY d.dispatch_no,i.id
</script>
""")
java.util.List<CalendarSource> calendarSources(@Param("numbers") java.util.List<String> dispatchNumbers);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo); FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
@@ -4,62 +4,23 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */ /** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */
@Mapper @Mapper
public interface CustomerOrderProgressMapper { public interface CustomerOrderProgressMapper {
/** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */ /** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */
@Select(""" @Select("""
<script> <script>
WITH scheduled AS ( WITH scheduled AS (
SELECT line.order_id,SUM(a.planned_package_count) AS packages SELECT i.source_order_id AS order_id,SUM(i.package_count) AS packages
FROM ll_dispatch_order d FROM ll_outbound_order b
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id JOIN ll_outbound_pallet_item i ON i.outbound_order_id=b.id AND i.customer_id=b.customer_id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
<if test="orderIds != null"> <if test="orderIds != null">
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId} JOIN ll_order outbound_source ON outbound_source.id=i.source_order_id AND outbound_source.customer_id=#{customerId}
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach> AND outbound_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if> </if>
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE' WHERE i.customer_id=#{customerId}
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING') AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')
AND i.warehouse_pallet_id IS NULL GROUP BY i.source_order_id
GROUP BY line.order_id
UNION ALL
SELECT b.inbound_order_id AS order_id,SUM(p.available_packages+COALESCE(pk.packages,0)) AS packages
FROM ll_dispatch_order d
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
JOIN ll_inventory_position p ON p.customer_id=i.customer_id AND p.pallet_id=i.warehouse_pallet_id
AND p.overseas_warehouse_id=i.overseas_warehouse_id
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=i.customer_id
<if test="orderIds != null">
JOIN ll_order stock_source ON stock_source.id=b.inbound_order_id AND stock_source.customer_id=#{customerId}
AND stock_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
LEFT JOIN (SELECT pick.dispatch_item_id,pick.inventory_position_id,SUM(pick.packages) AS packages
FROM ll_dispatch_pda_pick pick
<if test="orderIds != null">
JOIN ll_inventory_position scoped_position ON scoped_position.id=pick.inventory_position_id
AND scoped_position.customer_id=pick.customer_id
JOIN ll_inventory_balance scoped_balance ON scoped_balance.id=scoped_position.balance_id
AND scoped_balance.customer_id=pick.customer_id
JOIN ll_order scoped_order ON scoped_order.id=scoped_balance.inbound_order_id
AND scoped_order.customer_id=pick.customer_id
AND scoped_order.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE pick.customer_id=#{customerId}
GROUP BY pick.dispatch_item_id,pick.inventory_position_id) pk
ON pk.dispatch_item_id=i.id AND pk.inventory_position_id=p.id
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
AND (i.plan_pallet_id IS NULL OR EXISTS (
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
<if test="orderIds != null">
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE a.pallet_id=i.plan_pallet_id AND line.order_id=b.inbound_order_id))
GROUP BY b.inbound_order_id
), departed AS ( ), departed AS (
SELECT a.order_id,SUM(a.packages) AS packages SELECT a.order_id,SUM(a.packages) AS packages
FROM ll_dispatch_pda_pick_order a FROM ll_dispatch_pda_pick_order a
@@ -359,7 +359,7 @@ components:
schemas: schemas:
TripCalendarEntry: TripCalendarEntry:
type: object type: object
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons] required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons]
properties: properties:
sourceType: {type: string, enum: [DISPATCH]} sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由} dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
@@ -375,6 +375,11 @@ components:
departedAt: {type: string, format: date-time, nullable: true} departedAt: {type: string, format: date-time, nullable: true}
completedAt: {type: string, format: date-time, nullable: true} completedAt: {type: string, format: date-time, nullable: true}
stops: {type: array, items: {$ref: '#/components/schemas/Stop'}} stops: {type: array, items: {$ref: '#/components/schemas/Stop'}}
containerNumbers:
type: array
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
items: {type: string}
uniqueItems: true
incompleteReasons: incompleteReasons:
type: array type: array
items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]} items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]}
@@ -8,9 +8,10 @@ paths:
summary: Order按认证客户读取排车和实际派送进度 summary: Order按认证客户读取排车和实际派送进度
description: >- description: >-
只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。 只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。
正式排车单PENDING_DEPARTURE/PICKING/READY_TO_DEPART按计划或库存来源计算,预排草稿和取消单不计入。 已排车scheduled按有效待出库单的不可变来源明细及箱数计算,与平台出库单节点一致;仅生成排车单号、审核中、审核通过但未生成出库单均不计入。
派送必须已实际DEPARTED、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。 取消的出库单及已实际出库的单据不计入scheduled;排车审核、真实拣货核平、装车确认及安排派送仍按原流程执行。
混托使用所有库存及不可变拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。 派送必须排车单ACTIVE且处于DELIVERING阶段、具有实际发车时间、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
混托使用全部不可变出库及拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
security: [{internalServiceToken: []}] security: [{internalServiceToken: []}]
requestBody: requestBody:
required: true required: true
@@ -62,4 +62,24 @@ class FleetTripCalendarMapperTest {
bindDispatches(jdbc); bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001); assertThat(mapper.calendar(from,to)).hasSize(2001);
} }
/** 实际Mapper批读同单跨客户多柜,排除其他主单与取消主单,不用货物行放大日历。 */
@Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(80) UNIQUE,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_json VARCHAR(2000))");
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'PC-A','ACTIVE'),(2,'PC-B','ACTIVE'),(3,'PC-OTHER','ACTIVE'),(4,'PC-CANCELLED','CANCELLED')");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(11,1,'101',?), (12,1,'102',?), (13,1,'101',?), (21,2,'103',?), (31,3,'101',?), (41,4,'101',?)",
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}",
"{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
var repository=new FleetTripCalendarRepository(mapper);
assertThat(repository.sourceSnapshots(List.of())).isEmpty();
var rows=repository.sourceSnapshots(List.of("PC-A","PC-B","PC-CANCELLED"));
assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B");
assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly(
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}");
}
} }
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class FleetTripCalendarServiceTest { class FleetTripCalendarServiceTest {
final FleetTripMapper trips=mock(FleetTripMapper.class); final FleetTripCalendarRepository trips=mock(FleetTripCalendarRepository.class);
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class); final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test"); final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44)); final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
@@ -107,4 +107,51 @@ class FleetTripCalendarServiceTest {
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")); var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
controller.calendar(platform,from,to);verify(calendar).calendar(from,to); controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
} }
/** 同车全部来源只批读一次,跨客户重复柜号去重,多柜完整保留。 */
@Test void readsAllContainerNumbersInOneBatchAndPreservesTheLocalDateFacts() {
var first=trip("PC-A",1L);first.setPlannedDepartureAt(LocalDateTime.of(2026,10,22,16,0));
when(trips.calendar(any(),any())).thenReturn(List.of(first,trip("PC-B",1L)));
when(trips.sourceSnapshots(List.of("PC-A","PC-B"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\" TSTU1234567 \",\"planNo\":\"PLAN-A\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU1234567\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}"),
new FleetTripCalendarRepository.Source("PC-B","{}"),
new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}")));
metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of());
var result=service.calendar(from,to);
assertThat(result.records()).hasSize(2);
assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321");
assertThat(result.records().get(1).containerNumbers()).isEmpty();
assertThat(result.records().get(0).plannedDepartureAt()).isEqualTo(Instant.parse("2026-10-22T16:00:00Z"));
assertThat(result.records().get(0).originTimezone()).isEqualTo("America/Los_Angeles");
assertThat(result.unresolvedRecords()).isEmpty();
verify(trips).calendar(any(),any());verify(trips).sourceSnapshots(List.of("PC-A","PC-B"));verifyNoMoreInteractions(trips);
verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L)));
verifyNoMoreInteractions(warehouses);
}
/** 缺时区的主单仍保留真实柜号,不将货物信息丢失误作起运地已核实。 */
@Test void keepsKnownContainersOnAnUnresolvedOrigin() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L)));
when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}")));
metadata(List.of(warehouse(1,"US",null)),List.of());
var result=service.calendar(from,to);assertThat(result.records()).isEmpty();
assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{
assertThat(row.containerNumbers()).containsExactly("TSTU1234567");
assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
});
}
/** 损坏或非当前正式文本快照明确失败,不伪造空数组或静默隐藏整月。 */
@Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L)));
for(var invalid:Arrays.asList(null,"","not-json","null","[]","{\"containerNo\":123}","{\"containerNo\":[\"TSTU1234567\"]}","{} {}")) {
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A",invalid)));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("柜号资料暂不可用");
}
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}")));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用");
verifyNoInteractions(warehouses);
}
} }
@@ -13,16 +13,19 @@ import static org.assertj.core.api.Assertions.*;
class CustomerOrderProgressMapperTest { class CustomerOrderProgressMapperTest {
private JdbcTemplate jdbc; private JdbcTemplate jdbc;
private CustomerOrderProgressMapper mapper; private CustomerOrderProgressMapper mapper;
/** 两客户共用一单一任务,计划和在途均按各自真实明细归属统计。 */ /** 两客户共用一单一任务,出库和在途均按各自真实明细归属统计。 */
@Test void sharedDispatchAndTaskKeepBothCustomerProgressSeparate() { @Test void sharedDispatchAndTaskKeepBothCustomerProgressSeparate() {
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(101,10,'22',501,'W1','FBA2')"); jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(101,10,'22',501,'W1','FBA2')");
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(501,1002,30)"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(501,1002,30)");
pendingOutbound(10000,"11",1,10);
pendingOutbound(10000,"11",2,20);
pendingOutbound(10002,"22",3,30);
assertThat(mapper.sources("11",null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L); assertThat(mapper.sources("11",null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.scheduledPackages()).isEqualTo(30)); assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.scheduledPackages()).isEqualTo(30));
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)"); jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700),(601,'22',101,401,7,700)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700),(601,'22',101,401,7,700)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4),(901,601,'22',3,7)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4),(901,601,'22',3,7)");
jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','OUTBOUNDED'),(10002,'22','OUTBOUNDED')"); jdbc.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");
jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1'),(10003,'22',10,10002,'DELIVERING','CODE:FBA2')"); jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1'),(10003,'22',10,10002,'DELIVERING','CODE:FBA2')");
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP"); jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP");
assertThat(mapper.sources("11",null)).extracting(s->s.deliveringPackages()).containsExactly(4L,0L); assertThat(mapper.sources("11",null)).extracting(s->s.deliveringPackages()).containsExactly(4L,0L);
@@ -45,6 +48,7 @@ class CustomerOrderProgressMapperTest {
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)");
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT,outbound_order_id BIGINT,status VARCHAR,delivery_group_key VARCHAR)"); jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT,outbound_order_id BIGINT,status VARCHAR,delivery_group_key VARCHAR)");
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR,status VARCHAR)"); jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR,status VARCHAR)");
jdbc.execute("CREATE TABLE ll_outbound_pallet_item(outbound_order_id BIGINT,customer_id VARCHAR,source_order_id BIGINT,package_count INT)");
jdbc.update("INSERT INTO ll_order VALUES(1,'11','101','201','FIRSTLEG','FBA',10),(2,'11','102','202','FIRSTLEG','FBA',20),(3,'22','103','203','FIRSTLEG','FBA',30)"); jdbc.update("INSERT INTO ll_order VALUES(1,'11','101','201','FIRSTLEG','FBA',10),(2,'11','102','202','FIRSTLEG','FBA',20),(3,'22','103','203','FIRSTLEG','FBA',30)");
jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'ACTIVE','BEFORE_LOADING',NULL)"); jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'ACTIVE','BEFORE_LOADING',NULL)");
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(100,10,'11',500,'W1','FBA1')"); jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(100,10,'11',500,'W1','FBA1')");
@@ -54,24 +58,40 @@ class CustomerOrderProgressMapperTest {
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class);
} }
/** 计划托盘上的所有本客户来源均计入,取消后不再计入。 */ /** 排车号及审核阶段不代表有效出库,生成出库后才计入,取消后自动移出。 */
@Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() { @Test void onlyEffectiveOutboundSourcesCountAsScheduled() {
jdbc.execute("ALTER TABLE ll_dispatch_order ADD review_state VARCHAR");
for (String review : java.util.List.of("DRAFT","DISPATCH_PENDING","DISPATCH_REJECTED","LOADING_PENDING","LOADING_REJECTED","APPROVED")) {
jdbc.update("UPDATE ll_dispatch_order SET review_state=?",review);
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,null).progress()).isEmpty();
}
jdbc.update("UPDATE ll_dispatch_order SET stage='AFTER_LOADING'");
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
pendingOutbound(10000,"11",1,10);
pendingOutbound(10000,"11",2,20);
var sources=mapper.sources("11", null);assertThat(sources).hasSize(2); var sources=mapper.sources("11", null);assertThat(sources).hasSize(2);
assertThat(sources.get(0).scheduledPackages()).isEqualTo(10); assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20); assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0)); assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
assertThat(sources).allMatch(s->s.deliveringPackages()==0); assertThat(sources).allMatch(s->s.deliveringPackages()==0);
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); for (String status : java.util.List.of("CANCELLED","CANCELED","OUTBOUNDED")) {
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0); jdbc.update("UPDATE ll_outbound_order SET status=?",status);
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
}
assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0); assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0);
} }
/** 已收货使用实际库存和扫码,不与计划量重复累计。 */ /** 已收货和扫码不能提前推进,出库来源快照不随剩余库存变化。 */
@Test void receivedPalletUsesInventoryAndActualPicksWithoutCountingBothPlanAndStock() { @Test void scheduledUsesOutboundSnapshotInsteadOfPlannedOrRemainingInventory() {
jdbc.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=800"); jdbc.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=800");
jdbc.update("INSERT INTO ll_inventory_balance VALUES(300,'11',1),(301,'11',2)"); jdbc.update("INSERT INTO ll_inventory_balance VALUES(300,'11',1),(301,'11',2)");
jdbc.update("INSERT INTO ll_inventory_position VALUES(400,'11',800,'W1',300,6),(401,'11',800,'W1',301,20)"); jdbc.update("INSERT INTO ll_inventory_position VALUES(400,'11',800,'W1',300,6),(401,'11',800,'W1',301,20)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)");
var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(10); assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
pendingOutbound(10000,"11",1,4);
pendingOutbound(10000,"11",2,20);
jdbc.update("UPDATE ll_inventory_position SET available_packages=0");
var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(4);
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20); assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0)); assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
} }
@@ -95,8 +115,10 @@ class CustomerOrderProgressMapperTest {
assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0); assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0);
} }
/** 同源多入仓片段完整返回,过滤不得扩大到其他订单或客户。 */
@Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() { @Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() {
jdbc.update("INSERT INTO ll_order VALUES(4,'11','101','204','FIRSTLEG','FBA',5)"); jdbc.update("INSERT INTO ll_order VALUES(4,'11','101','204','FIRSTLEG','FBA',5)");
pendingOutbound(10000,"11",1,10);
var own=mapper.sources("11",java.util.List.of("101","103")); var own=mapper.sources("11",java.util.List.of("101","103"));
assertThat(own).extracting(s->s.orderId()).containsExactly("101","101"); assertThat(own).extracting(s->s.orderId()).containsExactly("101","101");
assertThat(own).extracting(s->s.scheduledPackages()).containsExactly(10L,0L); assertThat(own).extracting(s->s.scheduledPackages()).containsExactly(10L,0L);
@@ -104,4 +126,30 @@ class CustomerOrderProgressMapperTest {
assertThat(mapper.sources("11",java.util.List.of("999"))).isEmpty(); assertThat(mapper.sources("11",java.util.List.of("999"))).isEmpty();
} }
/** 多张出库只汇总本客户的有效来源数量,不借用其他来源或客户单据。 */
@Test void splitOutboundQuantitiesExcludeCancelledAndForeignDocuments() {
pendingOutbound(10000,"11",1,4);
pendingOutbound(10001,"11",1,6);
pendingOutbound(10002,"11",2,20);
jdbc.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=10001");
jdbc.update("INSERT INTO ll_outbound_order VALUES(10003,'22','WAIT_OUTBOUND')");
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(10003,'11',1,100),(10000,'22',3,100),(10003,'22',1,100)");
assertThat(mapper.sources("11", null)).extracting(s->s.scheduledPackages()).containsExactly(4L,20L);
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,java.util.List.of(101L)).progress())
.singleElement().satisfies(p->{assertThat(p.stage()).isEqualTo("scheduled");assertThat(p.partial()).isTrue();});
assertThat(mapper.sources("22", null)).allMatch(s->s.scheduledPackages()==0);
jdbc.update("UPDATE ll_outbound_order SET status='WAIT_OUTBOUND' WHERE id=10001");
assertThat(mapper.sources("11", null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,java.util.List.of(101L)).progress())
.singleElement().satisfies(p->{assertThat(p.stage()).isEqualTo("scheduled");assertThat(p.partial()).isFalse();});
}
/** 创建同客户的待出库单及精确来源明细,允许一单包含多个来源。 */
private void pendingOutbound(long outboundId,String customerId,long sourceOrderId,int packages) {
if (jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE id=?",Integer.class,outboundId)==0) {
jdbc.update("INSERT INTO ll_outbound_order VALUES(?,?,'WAIT_OUTBOUND')",outboundId,customerId);
}
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(?,?,?,?)",outboundId,customerId,sourceOrderId,packages);
}
} }
@@ -716,13 +716,22 @@ public class UnifiedOrderQueryService {
|| StringUtils.hasText(query.measurementReviewStatus()); || StringUtils.hasText(query.measurementReviewStatus());
} }
/** 保留内部调用的原始异常,便于按请求链路定位连接和协议故障。 */
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) { private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
try { return invocation.call(); } try { return invocation.call(); }
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); } catch (FeignException exception) {
catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); } throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用").initCause(exception);
}
catch (Exception exception) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败").initCause(exception);
}
} }
/** 失败关闭时保留下游错误码与链路标识,不记录业务载荷或服务令牌。 */
private <T> T require(ApiResponse<T> response, String message) { private <T> T require(ApiResponse<T> response, String message) {
if (response == null || !response.successful() || response.data() == null) { if (response == null || !response.successful() || response.data() == null) {
LOGGER.warn("头程查询响应无效 operation={} downstreamCode={} downstreamMessage={} downstreamRequestId={} downstreamTraceId={}",
message, response == null ? null : response.code(), response == null ? null : response.message(),
response == null ? null : response.requestId(), response == null ? null : response.traceId());
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
} }
return response.data(); return response.data();
@@ -112,11 +112,13 @@ test('流水断言逐行核对归属和业务引用,不能仅凭合计相等
assert.equal(exactReceiptLedger(undefined, []), false) assert.equal(exactReceiptLedger(undefined, []), false)
}) })
test('真实退货验收检查五类完整编号且保留同键重放检查', async () => { test('真实退货验收使用TH及RR和RB日流水并保留原箱码与重放检查', async () => {
const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8')
for (const prefix of ['RT', 'RETU', 'RR', 'RB', 'RC']) { for (const prefix of ['TH', 'RR', 'RB']) {
assert.ok(source.includes(`/^${prefix}-[0-9A-Z]{25}$/`), prefix) assert.ok(source.includes(`/^${prefix}[0-9]{12}$/`), prefix)
} }
assert.ok(source.includes('data.order.lastlegOrderNo === data.order.returnNo'))
assert.ok(source.includes('/^BX-[0-9A-Z]+$/'))
assert.ok(source.includes('同键并发合箱后重试仍返回原箱')) assert.ok(source.includes('同键并发合箱后重试仍返回原箱'))
assert.ok(source.includes('签收重放不重复产生流水')) assert.ok(source.includes('签收重放不重复产生流水'))
}) })
@@ -108,7 +108,10 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, unitCode: `PKG-${data.order.returnNo}` } return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, unitCode: `PKG-${data.order.returnNo}` }
} }
function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) {
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } // 合成包裹明确原包装选择与实测输入,使边界场景进入待验证的库存/并发规则。
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode,
originalPackaging: true, length: 30, width: 20, height: 10, weight: 2,
items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey }
} }
async function receive(order, operator = picker) { async function receive(order, operator = picker) {
const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order))
@@ -259,11 +262,12 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
check('三件库存同时各取两件仅一次成功', partialResponses[0], partialOutcome.successes === 1 check('三件库存同时各取两件仅一次成功', partialResponses[0], partialOutcome.successes === 1
&& partialOutcome.conflicts === 1 && partialOutcome.unexpected === 0, JSON.stringify(partialOutcome)) && partialOutcome.conflicts === 1 && partialOutcome.unexpected === 0, JSON.stringify(partialOutcome))
const [partialState] = await snapshot([partial]) const [partialState] = await snapshot([partial])
// PARCEL 不计纸箱数;剩余包裹和新 BOX 是两个库存单元,但只有一个纸箱。
check('并发部分合箱保持原箱一件新箱两件总量三件', null, Number(partialState.cartons.total) === 2 check('并发部分合箱保持原箱一件新箱两件总量三件', null, Number(partialState.cartons.total) === 2
&& rows(partialState.cartons).every(row => row.status === 'AVAILABLE') && rows(partialState.cartons).every(row => row.status === 'AVAILABLE')
&& rows(partialState.cartons).some(row => row.cartonMark === partial.cartonMark && Number(row.totalQuantity) === 1) && rows(partialState.cartons).some(row => row.cartonMark === partial.cartonMark && Number(row.totalQuantity) === 1)
&& rows(partialState.cartons).some(row => row.cartonMark !== partial.cartonMark && Number(row.totalQuantity) === 2) && rows(partialState.cartons).some(row => row.cartonMark !== partial.cartonMark && Number(row.totalQuantity) === 2)
&& rows(partialState.summary).some(row => row.skuCode === partial.sku && Number(row.availableQuantity) === 3 && Number(row.cartonQuantity) === 2)) && rows(partialState.summary).some(row => row.skuCode === partial.sku && Number(row.availableQuantity) === 3 && Number(row.cartonQuantity) === 1))
} }
}) })
@@ -306,7 +310,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409]) ...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409])
} }
const [state] = await snapshot([order]) const [state] = await snapshot([order])
check('混合失败后两SKU均保持两件且各自一个来源箱', null, Number(state.cartons.total) === 2 check('混合失败后两SKU各两件且保留同一原包裹', null, Number(state.cartons.total) === 1
&& [state.summary, ...state.additionalSummaries].every(page => rows(page).length === 1 && Number(rows(page)[0].availableQuantity) === 2)) && [state.summary, ...state.additionalSummaries].every(page => rows(page).length === 1 && Number(rows(page)[0].availableQuantity) === 2))
}) })
@@ -318,11 +322,11 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
const responses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', MERGE, body))) const responses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', MERGE, body)))
for (const [index, response] of responses.entries()) check(`同键并发合箱请求${index + 1}成功`, response, ok(response)) for (const [index, response] of responses.entries()) check(`同键并发合箱请求${index + 1}成功`, response, ok(response))
check('同键并发合箱复用同一目标箱', responses[0], responses.every(ok) check('同键并发合箱复用同一目标箱', responses[0], responses.every(ok)
&& /^RC-[0-9A-Z]{25}$/.test(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark) && /^BX-[0-9A-Z]+$/.test(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark)
const [state] = await snapshot([order]) const [state] = await snapshot([order])
check('同键并发合箱只扣一次且原箱新箱各一件', null, Number(state.cartons.total) === 2 check('同键并发合箱只扣一次且原箱新箱各一件', null, Number(state.cartons.total) === 2
&& rows(state.cartons).every(row => Number(row.totalQuantity) === 1 && row.status === 'AVAILABLE') && rows(state.cartons).every(row => Number(row.totalQuantity) === 1 && row.status === 'AVAILABLE')
&& rows(state.summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 2 && Number(row.cartonQuantity) === 2)) && rows(state.summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 2 && Number(row.cartonQuantity) === 1))
const original = responses.find(ok)?.body.data?.cartonMark const original = responses.find(ok)?.body.data?.cartonMark
const replay = await request(picker.token, 'POST', MERGE, body) const replay = await request(picker.token, 'POST', MERGE, body)
check('同键并发合箱后重试仍返回原箱', replay, ok(replay) && Boolean(original) && replay.body.data?.cartonMark === original) check('同键并发合箱后重试仍返回原箱', replay, ok(replay) && Boolean(original) && replay.body.data?.cartonMark === original)
@@ -352,7 +356,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 20 })) skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 20 }))
check('同码库存只返回当前客户当前仓的准确数量', null, Number(data.total) === 1 && rows(data).length === 1 check('同码库存只返回当前客户当前仓的准确数量', null, Number(data.total) === 1 && rows(data).length === 1
&& rows(data)[0].skuCode === sku && String(rows(data)[0].overseasWarehouseId) === String(order.warehouseId) && rows(data)[0].skuCode === sku && String(rows(data)[0].overseasWarehouseId) === String(order.warehouseId)
&& Number(rows(data)[0].availableQuantity) === order.quantity && Number(rows(data)[0].cartonQuantity) === 1) && Number(rows(data)[0].availableQuantity) === order.quantity && Number(rows(data)[0].cartonQuantity) === 0)
} }
for (const [token, expected] of [[merchantToken, [a, c]], [tokenB, [b]]]) { for (const [token, expected] of [[merchantToken, [a, c]], [tokenB, [b]]]) {
const data = await good(token, 'GET', '客户按同码查询自己全部退货单', url(ORDERS, { skuCode: sku, page: 1, size: 20 })) const data = await good(token, 'GET', '客户按同码查询自己全部退货单', url(ORDERS, { skuCode: sku, page: 1, size: 20 }))