Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c3d36c20 |
@@ -40,14 +40,8 @@ public class GlobalExceptionHandler {
|
||||
@Autowired(required = false)
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
||||
@ExceptionHandler(BizException.class)
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
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.core.MethodParameter;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
@@ -42,37 +39,6 @@ class GlobalExceptionHandlerTest {
|
||||
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
|
||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
|
||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||
String reviewStatus, String sourceStatus,
|
||||
boolean statementGenerated) {}
|
||||
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
|
||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||
BigDecimal amount, String method, String referenceNo,
|
||||
List<String> voucherFileIds, long createdBy, String createdByName,
|
||||
LocalDateTime createdAt,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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);
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
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,7 +12,6 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -438,14 +437,7 @@ public class FinancePhase1OperationService {
|
||||
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 String json(Object value) { try { return objectMapper.writeValueAsString(value); } 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 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 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) {
|
||||
JsonNode value = node.path(field);
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -307,14 +306,7 @@ public class FinanceService {
|
||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||
};
|
||||
}
|
||||
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
||||
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 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 static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -45,9 +44,23 @@ import java.util.UUID;
|
||||
public class FinanceSettlementService {
|
||||
private static final String CNY = "CNY";
|
||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||
private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
|
||||
private static final String SETTLEMENT_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 com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
@@ -57,10 +70,8 @@ public class FinanceSettlementService {
|
||||
@Autowired
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator,
|
||||
FinanceDocumentNumberGenerator documentNumbers,
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
||||
FinanceDocumentNumberGenerator documentNumbers) {
|
||||
this.jdbc = jdbc;
|
||||
this.details = details;
|
||||
this.objectMapper = objectMapper;
|
||||
this.voucherValidator = voucherValidator;
|
||||
this.supplierValidator = supplierValidator;
|
||||
@@ -171,21 +182,33 @@ public class FinanceSettlementService {
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
|
||||
public SettlementDetail getSettlement(long settlementId) {
|
||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||
List<Map<String, Object>> records = details.summary(settlementId);
|
||||
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||
SettlementSummary summary = summary(records.get(0));
|
||||
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
||||
Map<String, Object> row = one(
|
||||
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
|
||||
"结算单不存在", settlementId);
|
||||
SettlementSummary summary = summary(row);
|
||||
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();
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||
.stream().map(this::transaction).toList());
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
||||
"SELECT id,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=? AND settlement_id=? ORDER BY created_at,id",
|
||||
summary.customerId(), settlementId).stream().map(this::transaction).toList());
|
||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
|
||||
.stream().map(this::transaction).toList());
|
||||
transactions.addAll(jdbc.queryForList(
|
||||
"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=? 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 = details.audits(summary.customerId(), settlementId);
|
||||
List<Map<String, Object>> audits = jdbc.queryForList(
|
||||
"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<>();
|
||||
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));
|
||||
@@ -453,9 +476,8 @@ public class FinanceSettlementService {
|
||||
text(row, "status"), true);
|
||||
}
|
||||
|
||||
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
|
||||
private SettlementTransaction transaction(Map<String, Object> row) {
|
||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
|
||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
||||
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(),
|
||||
text(row, "created_by_name"),
|
||||
@@ -501,13 +523,10 @@ public class FinanceSettlementService {
|
||||
return documentNumbers.next("PAY");
|
||||
}
|
||||
|
||||
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
String canonical = objectMapper.writer()
|
||||
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception failure) {
|
||||
throw error("FIN_001", "幂等请求哈希失败");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -695,9 +694,8 @@ public class FinanceWalletService {
|
||||
timestamp(rs.getTimestamp("reviewed_at")));
|
||||
}
|
||||
|
||||
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||
private String hash(Object value) {
|
||||
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||
}
|
||||
private String fileHash(MultipartFile file) {
|
||||
|
||||
+2
-7
@@ -10,7 +10,6 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -522,13 +521,9 @@ public class ReconciliationStatementService {
|
||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||
}
|
||||
|
||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||
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_001", "幂等请求哈希失败"); }
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
}
|
||||
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
|
||||
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
|
||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
||||
"createdBy", "createdByName", "transactionNo");
|
||||
"createdBy", "createdByName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
||||
|
||||
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
|
||||
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 f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
||||
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_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
||||
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
|
||||
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("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
|
||||
@@ -258,7 +258,6 @@ class FinanceSettlementBehaviorTest {
|
||||
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
|
||||
.isEqualTo("PAYMENT");
|
||||
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"),
|
||||
any(), any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
@@ -523,7 +522,6 @@ class FinanceSettlementBehaviorTest {
|
||||
.isEqualTo("RECEIPT");
|
||||
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
|
||||
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
|
||||
@@ -625,15 +623,8 @@ class FinanceSettlementBehaviorTest {
|
||||
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("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(),
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
||||
}
|
||||
|
||||
private static FinanceSupplierValidator supplierValidator() {
|
||||
@@ -666,7 +657,7 @@ class FinanceSettlementBehaviorTest {
|
||||
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();
|
||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), 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 id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -67,11 +67,10 @@ public class PlatformColoadGroupController {
|
||||
idempotencyKey, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
/** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */
|
||||
@GetMapping("/by-inbound-code/{inboundCode}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
@Operation(summary = "按入仓码查询同柜组业务摘要",
|
||||
description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。")
|
||||
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。")
|
||||
public ApiResponse<BusinessSummary> byInboundCode(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
|
||||
|
||||
@@ -71,7 +71,6 @@ public final class ColoadGroupModels {
|
||||
|
||||
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
|
||||
public record BusinessSummary(
|
||||
String groupNo,
|
||||
String status,
|
||||
int memberCount,
|
||||
List<BusinessMember> members
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ public interface ColoadGroupMapper {
|
||||
@Param("inboundId") long inboundId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus
|
||||
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus
|
||||
FROM fl_inbound_order requested
|
||||
JOIN fl_coload_group_member member
|
||||
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
-1
@@ -59,7 +59,6 @@ public final class ColoadPersistenceRows {
|
||||
@Setter
|
||||
public static class InboundGroupLookupRow {
|
||||
private Long groupId;
|
||||
private String groupNo;
|
||||
private String groupStatus;
|
||||
}
|
||||
|
||||
|
||||
+3
-6
@@ -47,8 +47,6 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
|
||||
@Resource
|
||||
private ColoadGroupMapper mapper;
|
||||
@Resource
|
||||
private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository;
|
||||
@Resource private PendingReviewOrderClient pendingReviewOrders;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
||||
@@ -120,11 +118,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
return requiredSnapshot(groupId);
|
||||
}
|
||||
|
||||
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
|
||||
@Transactional(readOnly = true)
|
||||
public BusinessSummary queryByInboundCode(String inboundCode) {
|
||||
String code = normalizedInboundCode(inboundCode);
|
||||
var groups = queryRepository.groupsByInboundCode(code).stream()
|
||||
var groups = mapper.groupsByInboundCode(code).stream()
|
||||
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
|
||||
.toList();
|
||||
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
|
||||
@@ -134,7 +131,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
|
||||
long groupId = groups.get(0).getGroupId();
|
||||
String status = groups.get(0).getGroupStatus();
|
||||
var memberRows = queryRepository.effectiveMemberCodes(groupId);
|
||||
var memberRows = mapper.effectiveMemberCodes(groupId);
|
||||
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
|
||||
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|
||||
|| trim(row.getInboundCode()) == null)) {
|
||||
@@ -145,7 +142,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||
throw conflict("同柜不可拆组存在重复入仓片段业务号");
|
||||
}
|
||||
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
|
||||
return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
|
||||
return new BusinessSummary(status, members.size(), members);
|
||||
}
|
||||
|
||||
private long requiredCurrentGroupId(String inboundCode) {
|
||||
|
||||
@@ -24,7 +24,7 @@ public class WorkflowTabService {
|
||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).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);
|
||||
}
|
||||
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
@@ -32,24 +32,19 @@ public class WorkflowTabService {
|
||||
if(!sources.isEmpty()) {
|
||||
try {
|
||||
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
||||
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()));
|
||||
if(response==null || !response.successful() || response.data()==null) throw unavailable();
|
||||
var allowed=new HashSet<>(sources);
|
||||
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()));
|
||||
}
|
||||
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
||||
+" 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);
|
||||
}
|
||||
if(overseas.size()!=sources.size()) throw unavailable();
|
||||
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
||||
}
|
||||
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
||||
for(var fact:facts) {
|
||||
var inbound=expected.get(fact.inboundId());
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
|
||||
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));
|
||||
}
|
||||
@@ -82,9 +77,5 @@ public class WorkflowTabService {
|
||||
return keys;
|
||||
}
|
||||
/** 下游缺失或归属异常不能生成部分数量。 */
|
||||
private static BizException unavailable(String reason) {
|
||||
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
|
||||
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
|
||||
.initCause(new IllegalStateException(reason));
|
||||
}
|
||||
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ paths:
|
||||
summary: 按入仓码查询同柜组业务摘要
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
|
||||
返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||
页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
|
||||
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
|
||||
parameters:
|
||||
- name: inboundCode
|
||||
in: path
|
||||
@@ -147,9 +147,8 @@ components:
|
||||
ColoadGroupBusinessSummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [groupNo, status, memberCount, members]
|
||||
required: [status, memberCount, members]
|
||||
properties:
|
||||
groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致}
|
||||
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
|
||||
memberCount: {type: integer, minimum: 1}
|
||||
members:
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest {
|
||||
.doesNotContain("destinationCode")
|
||||
.doesNotContain("expectedVersion")
|
||||
.doesNotContain("platform:firstleg:coload");
|
||||
assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members");
|
||||
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
|
||||
assertThat(memberProperties).containsOnlyKeys("inboundCode");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -37,7 +37,7 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class})
|
||||
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class})
|
||||
class ColoadGroupServiceTest {
|
||||
@org.springframework.context.annotation.Configuration
|
||||
static class Numbers {
|
||||
@@ -71,7 +71,6 @@ class ColoadGroupServiceTest {
|
||||
|
||||
var result = service.queryByInboundCode(" IN-A ");
|
||||
|
||||
assertThat(result.groupNo()).isEqualTo("CLG202610210001");
|
||||
assertThat(result.status()).isEqualTo("ACTIVE");
|
||||
assertThat(result.memberCount()).isEqualTo(2);
|
||||
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
|
||||
@@ -575,7 +574,6 @@ class ColoadGroupServiceTest {
|
||||
private static InboundGroupLookupRow lookup(long groupId, String status) {
|
||||
InboundGroupLookupRow value = new InboundGroupLookupRow();
|
||||
value.setGroupId(groupId);
|
||||
value.setGroupNo("CLG202610210001");
|
||||
value.setGroupStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
+1
-2
@@ -86,8 +86,7 @@ class ColoadGroupSqlContractTest {
|
||||
.contains("requested.inbound_code=#{inboundCode}")
|
||||
.contains("requested.deleted=0")
|
||||
.contains("'ACTIVE','LOCKED','CONSUMED'")
|
||||
.contains("group_row.group_no groupNo")
|
||||
.doesNotContain("order_id=#{");
|
||||
.doesNotContain("group_no", "order_id=#{");
|
||||
assertThat(memberSql)
|
||||
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
|
||||
.contains("LEFT JOIN fl_inbound_order inbound")
|
||||
|
||||
@@ -25,15 +25,12 @@ class WorkflowBoundaryTest {
|
||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class)
|
||||
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
|
||||
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
var failure=new IllegalStateException("unavailable");
|
||||
when(downstream.read(any(),any())).thenThrow(failure);
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||
|
||||
+49
-10
@@ -4,23 +4,62 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */
|
||||
/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */
|
||||
@Mapper
|
||||
public interface CustomerOrderProgressMapper {
|
||||
/** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */
|
||||
/** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */
|
||||
@Select("""
|
||||
<script>
|
||||
WITH scheduled AS (
|
||||
SELECT i.source_order_id AS order_id,SUM(i.package_count) AS packages
|
||||
FROM ll_outbound_order b
|
||||
JOIN ll_outbound_pallet_item i ON i.outbound_order_id=b.id AND i.customer_id=b.customer_id
|
||||
SELECT line.order_id,SUM(a.planned_package_count) AS packages
|
||||
FROM ll_dispatch_order d
|
||||
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.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">
|
||||
JOIN ll_order outbound_source ON outbound_source.id=i.source_order_id AND outbound_source.customer_id=#{customerId}
|
||||
AND outbound_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
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 i.customer_id=#{customerId}
|
||||
AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')
|
||||
GROUP BY i.source_order_id
|
||||
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
|
||||
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
|
||||
AND i.warehouse_pallet_id IS NULL
|
||||
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 (
|
||||
SELECT a.order_id,SUM(a.packages) AS packages
|
||||
FROM ll_dispatch_pda_pick_order a
|
||||
|
||||
@@ -8,10 +8,9 @@ paths:
|
||||
summary: Order按认证客户读取排车和实际派送进度
|
||||
description: >-
|
||||
只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。
|
||||
已排车scheduled按有效待出库单的不可变来源明细及箱数计算,与平台出库单节点一致;仅生成排车单号、审核中、审核通过但未生成出库单均不计入。
|
||||
取消的出库单及已实际出库的单据不计入scheduled;排车审核、真实拣货核平、装车确认及安排派送仍按原流程执行。
|
||||
派送必须排车单ACTIVE且处于DELIVERING阶段、具有实际发车时间、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
|
||||
混托使用全部不可变出库及拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
|
||||
正式排车单PENDING_DEPARTURE/PICKING/READY_TO_DEPART按计划或库存来源计算,预排草稿和取消单不计入。
|
||||
派送必须已实际DEPARTED、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
|
||||
混托使用所有库存及不可变拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
+9
-57
@@ -13,19 +13,16 @@ import static org.assertj.core.api.Assertions.*;
|
||||
class CustomerOrderProgressMapperTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private CustomerOrderProgressMapper mapper;
|
||||
/** 两客户共用一单一任务,出库和在途均按各自真实明细归属统计。 */
|
||||
/** 两客户共用一单一任务,计划和在途均按各自真实明细归属统计。 */
|
||||
@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_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("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_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("UPDATE ll_outbound_order SET status='OUTBOUNDED'");
|
||||
jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','OUTBOUNDED'),(10002,'22','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("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP");
|
||||
assertThat(mapper.sources("11",null)).extracting(s->s.deliveringPackages()).containsExactly(4L,0L);
|
||||
@@ -48,7 +45,6 @@ class CustomerOrderProgressMapperTest {
|
||||
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_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_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')");
|
||||
@@ -58,40 +54,24 @@ class CustomerOrderProgressMapperTest {
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class);
|
||||
}
|
||||
/** 排车号及审核阶段不代表有效出库,生成出库后才计入,取消后自动移出。 */
|
||||
@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);
|
||||
/** 计划托盘上的所有本客户来源均计入,取消后不再计入。 */
|
||||
@Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() {
|
||||
var sources=mapper.sources("11", null);assertThat(sources).hasSize(2);
|
||||
assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
|
||||
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
|
||||
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
||||
assertThat(sources).allMatch(s->s.deliveringPackages()==0);
|
||||
for (String status : java.util.List.of("CANCELLED","CANCELED","OUTBOUNDED")) {
|
||||
jdbc.update("UPDATE ll_outbound_order SET status=?",status);
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
}
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0);
|
||||
}
|
||||
/** 已收货和扫码不能提前推进,出库来源快照不随剩余库存变化。 */
|
||||
@Test void scheduledUsesOutboundSnapshotInsteadOfPlannedOrRemainingInventory() {
|
||||
/** 已收货使用实际库存和扫码,不与计划量重复累计。 */
|
||||
@Test void receivedPalletUsesInventoryAndActualPicksWithoutCountingBothPlanAndStock() {
|
||||
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_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)");
|
||||
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);
|
||||
var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
|
||||
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
|
||||
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
||||
}
|
||||
@@ -115,10 +95,8 @@ class CustomerOrderProgressMapperTest {
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0);
|
||||
}
|
||||
|
||||
/** 同源多入仓片段完整返回,过滤不得扩大到其他订单或客户。 */
|
||||
@Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() {
|
||||
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"));
|
||||
assertThat(own).extracting(s->s.orderId()).containsExactly("101","101");
|
||||
assertThat(own).extracting(s->s.scheduledPackages()).containsExactly(10L,0L);
|
||||
@@ -126,30 +104,4 @@ class CustomerOrderProgressMapperTest {
|
||||
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,22 +716,13 @@ public class UnifiedOrderQueryService {
|
||||
|| StringUtils.hasText(query.measurementReviewStatus());
|
||||
}
|
||||
|
||||
/** 保留内部调用的原始异常,便于按请求链路定位连接和协议故障。 */
|
||||
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
|
||||
try { return invocation.call(); }
|
||||
catch (FeignException exception) {
|
||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用").initCause(exception);
|
||||
}
|
||||
catch (Exception exception) {
|
||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败").initCause(exception);
|
||||
}
|
||||
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
|
||||
catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); }
|
||||
}
|
||||
/** 失败关闭时保留下游错误码与链路标识,不记录业务载荷或服务令牌。 */
|
||||
private <T> T require(ApiResponse<T> response, String message) {
|
||||
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);
|
||||
}
|
||||
return response.data();
|
||||
|
||||
@@ -112,13 +112,11 @@ test('流水断言逐行核对归属和业务引用,不能仅凭合计相等
|
||||
assert.equal(exactReceiptLedger(undefined, []), false)
|
||||
})
|
||||
|
||||
test('真实退货验收使用TH及RR和RB日流水并保留原箱码与重放检查', async () => {
|
||||
test('真实退货验收检查五类完整编号且保留同键重放检查', async () => {
|
||||
const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8')
|
||||
for (const prefix of ['TH', 'RR', 'RB']) {
|
||||
assert.ok(source.includes(`/^${prefix}[0-9]{12}$/`), prefix)
|
||||
for (const prefix of ['RT', 'RETU', 'RR', 'RB', 'RC']) {
|
||||
assert.ok(source.includes(`/^${prefix}-[0-9A-Z]{25}$/`), 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('签收重放不重复产生流水'))
|
||||
})
|
||||
|
||||
@@ -108,10 +108,7 @@ 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}` }
|
||||
}
|
||||
function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) {
|
||||
// 合成包裹明确原包装选择与实测输入,使边界场景进入待验证的库存/并发规则。
|
||||
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 }
|
||||
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey }
|
||||
}
|
||||
async function receive(order, operator = picker) {
|
||||
const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order))
|
||||
@@ -262,12 +259,11 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
check('三件库存同时各取两件仅一次成功', partialResponses[0], partialOutcome.successes === 1
|
||||
&& partialOutcome.conflicts === 1 && partialOutcome.unexpected === 0, JSON.stringify(partialOutcome))
|
||||
const [partialState] = await snapshot([partial])
|
||||
// PARCEL 不计纸箱数;剩余包裹和新 BOX 是两个库存单元,但只有一个纸箱。
|
||||
check('并发部分合箱保持原箱一件新箱两件总量三件', null, Number(partialState.cartons.total) === 2
|
||||
&& 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) === 2)
|
||||
&& rows(partialState.summary).some(row => row.skuCode === partial.sku && Number(row.availableQuantity) === 3 && Number(row.cartonQuantity) === 1))
|
||||
&& rows(partialState.summary).some(row => row.skuCode === partial.sku && Number(row.availableQuantity) === 3 && Number(row.cartonQuantity) === 2))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -310,7 +306,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409])
|
||||
}
|
||||
const [state] = await snapshot([order])
|
||||
check('混合失败后两SKU各两件且保留同一原包裹', null, Number(state.cartons.total) === 1
|
||||
check('混合失败后两SKU均保持两件且各自一个来源箱', null, Number(state.cartons.total) === 2
|
||||
&& [state.summary, ...state.additionalSummaries].every(page => rows(page).length === 1 && Number(rows(page)[0].availableQuantity) === 2))
|
||||
})
|
||||
|
||||
@@ -322,11 +318,11 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
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))
|
||||
check('同键并发合箱复用同一目标箱', responses[0], responses.every(ok)
|
||||
&& /^BX-[0-9A-Z]+$/.test(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark)
|
||||
&& /^RC-[0-9A-Z]{25}$/.test(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark)
|
||||
const [state] = await snapshot([order])
|
||||
check('同键并发合箱只扣一次且原箱新箱各一件', null, Number(state.cartons.total) === 2
|
||||
&& 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) === 1))
|
||||
&& rows(state.summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 2 && Number(row.cartonQuantity) === 2))
|
||||
const original = responses.find(ok)?.body.data?.cartonMark
|
||||
const replay = await request(picker.token, 'POST', MERGE, body)
|
||||
check('同键并发合箱后重试仍返回原箱', replay, ok(replay) && Boolean(original) && replay.body.data?.cartonMark === original)
|
||||
@@ -356,7 +352,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
skuCode: sku, overseasWarehouseId: order.warehouseId, page: 1, size: 20 }))
|
||||
check('同码库存只返回当前客户当前仓的准确数量', null, Number(data.total) === 1 && rows(data).length === 1
|
||||
&& 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) === 0)
|
||||
&& Number(rows(data)[0].availableQuantity) === order.quantity && Number(rows(data)[0].cartonQuantity) === 1)
|
||||
}
|
||||
for (const [token, expected] of [[merchantToken, [a, c]], [tokenB, [b]]]) {
|
||||
const data = await good(token, 'GET', '客户按同码查询自己全部退货单', url(ORDERS, { skuCode: sku, page: 1, size: 20 }))
|
||||
|
||||
Reference in New Issue
Block a user