fix(numbering): expose coload and settlement payment business numbers

This commit is contained in:
2026-10-21 11:43:55 +08:00
parent 9fb39604c6
commit b4ff441a70
17 changed files with 181 additions and 57 deletions
@@ -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)); }
}
@@ -45,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;
@@ -71,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;
@@ -183,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));
@@ -477,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"),
@@ -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();
}
}
}
@@ -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) {
@@ -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")