This commit is contained in:
2026-08-19 09:42:07 +08:00
parent ff1d330c2d
commit b5864bfcda
56 changed files with 497 additions and 306 deletions
@@ -14,7 +14,7 @@ public interface WarehouseSupplierClient {
ApiResponse<SupplierSnapshot> get(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId,
@RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam String category);
@RequestParam(required = false) String category);
record SupplierSnapshot(long supplierId, String name, String category, String status) {}
}
@@ -131,7 +131,7 @@ public class FinanceFeeSourceEventService {
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("PAYABLE".equals(value.direction())) {
if (value.supplierId() == null || value.supplierId() <= 0
|| blank(value.supplierName()) || blank(value.supplierCategory())) {
|| blank(value.supplierName())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
}
var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory());
@@ -177,7 +177,7 @@ public class FinanceService {
String supplierName = null;
if (request.supplierId() != null) {
if (request.supplierId() <= 0) throw bad("供应商ID无效");
throw bad("人工成本缺少显式供应商类别,无法校验供应商");
supplierName = supplierValidator.requireActive(request.supplierId(), null).name();
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
throw bad("供应商名称必须来自Warehouse供应商主数据");
}
@@ -164,13 +164,8 @@ public class FinanceSettlementService {
}
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
if (sources.stream().anyMatch(row -> !StringUtils.hasText(text(row, "supplier_category")))) {
throw error("FIN_409", "应付费用来源缺少供应商类别快照");
}
Set<String> supplierCategories = sources.stream().map(row -> text(row, "supplier_category"))
.collect(java.util.stream.Collectors.toSet());
if (supplierCategories.size() != 1) throw error("FIN_409", "应付结算费用来源必须属于同一供应商类别");
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategories.iterator().next()).name();
String supplierCategory = supplierCategoryConstraint(sources);
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name();
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
LocalDate generatedDate = LocalDate.now();
@@ -384,15 +379,18 @@ public class FinanceSettlementService {
}
private String payableSupplierCategory(long customerId, long settlementId) {
Set<String> categories = jdbc.queryForList(
return supplierCategoryConstraint(jdbc.queryForList(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " +
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=?",
customerId, settlementId).stream()
.map(row -> text(row, "supplier_category"))
.collect(java.util.stream.Collectors.toSet());
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
return categories.iterator().next();
customerId, settlementId));
}
private String supplierCategoryConstraint(List<Map<String, Object>> sources) {
Set<String> categories = sources.stream().map(row -> text(row, "supplier_category"))
.filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
if (categories.size() > 1) throw error("FIN_409", "应付费用来源供应商类别约束不一致");
return categories.isEmpty() ? null : categories.iterator().next();
}
private Long reserve(long customerId, long operatorId, String operation, String key,
@@ -29,7 +29,8 @@ public class FinanceSupplierValidator {
var supplier = response == null ? null : response.data();
if (response == null || !response.successful() || supplier == null
|| supplier.supplierId() != supplierId || !"ACTIVE".equals(supplier.status())
|| !normalizedCategory.equals(supplier.category()) || !StringUtils.hasText(supplier.name())) {
|| (normalizedCategory != null && !normalizedCategory.equals(supplier.category()))
|| !StringUtils.hasText(supplier.name())) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
}
return supplier;
@@ -42,7 +43,8 @@ public class FinanceSupplierValidator {
}
private static String normalizeCategory(String category) {
String normalized = category == null ? "" : category.trim().toUpperCase(Locale.ROOT);
if (!StringUtils.hasText(category)) return null;
String normalized = category.trim().toUpperCase(Locale.ROOT);
if (!CATEGORIES.contains(normalized)) {
throw new FinanceEventException("FIN_409", "费用来源供应商类别无效");
}
@@ -103,6 +103,10 @@ CREATE TABLE IF NOT EXISTS fin_bill (
remark VARCHAR(500) NULL COMMENT '平台账单内部备注',
issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空',
issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空',
customer_confirmation_status VARCHAR(24) NOT NULL DEFAULT 'NOT_REQUIRED' COMMENT '客户确认状态:NOT_REQUIRED无需确认、PENDING_CONFIRMATION待确认、CONFIRMED已确认',
customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID',
customer_confirmed_by_name VARCHAR(128) NULL COMMENT '客户确认人姓名快照',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空',
void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
@@ -336,6 +340,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id),
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
@@ -14,10 +14,11 @@ class FinanceExportContractTest {
private static final List<String> TYPES = List.of(
"FINANCE_CUSTOMER_ACCOUNT_LIST", "FINANCE_CUSTOMER_ACCOUNT_LEDGER", "FINANCE_FEE_SOURCE_LIST",
"FINANCE_RECONCILIATION_STATEMENT_LIST", "FINANCE_RECEIVABLE_SETTLEMENT_LIST",
"FINANCE_RECEIVABLE_SETTLEMENT_DETAIL", "FINANCE_PAYABLE_SETTLEMENT_LIST", "FINANCE_PAYABLE_SETTLEMENT_DETAIL");
"FINANCE_RECEIVABLE_SETTLEMENT_DETAIL", "FINANCE_PAYABLE_SETTLEMENT_LIST", "FINANCE_PAYABLE_SETTLEMENT_DETAIL",
"FINANCE_RECEIVABLE_STATISTICS");
@Test
void keepsOpenApiAndAllFileDefinitionsOnTheSameEightTypes() throws Exception {
void keepsOpenApiAndAllFileDefinitionsOnTheSameNineTypes() throws Exception {
String openApi = Files.readString(resolve("docs", "财务管理", "openapi", "finance-phase1-five-pages-v1.yaml"), StandardCharsets.UTF_8);
String application = Files.readString(resolve("baiyue-firstleg", "file", "src", "main", "resources", "application.yml"), StandardCharsets.UTF_8);
String local = Files.readString(resolve("baiyue-firstleg", "config", "nacos", "local", "file-service.yml"), StandardCharsets.UTF_8);
@@ -46,10 +46,10 @@ class FinanceFeeSourceEventServiceTest {
void importsApprovedSettlementInboundFee() {
FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.inbound("test-token", 21, 31)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
when(firstleg.inbound("test-token", 21, 31, 1)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).inbound("test-token", 21, 31);
verify(firstleg).inbound("test-token", 21, 31, 1);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@@ -57,10 +57,10 @@ class FinanceFeeSourceEventServiceTest {
void importsApprovedSettlementContainerFeeWithCustomerOwnership() {
FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).container("test-token", 41, 31);
verify(firstleg).container("test-token", 41, 31, 1);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
}
@@ -69,12 +69,12 @@ class FinanceFeeSourceEventServiceTest {
FinanceFeeSourceEvent event = containerEvent("container-payable-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有往来单位",
"TRUCKING", 61L, "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
"TRUCKING", 61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-payable", 1);
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot));
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
@@ -82,14 +82,30 @@ class FinanceFeeSourceEventServiceTest {
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
.contains("\"supplierId\":71", "\"supplierName\":\"现有往来单位\"", "\"supplierCategory\":\"TRUCKING\"");
.contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
}
@Test
void importsPayableWithUnrestrictedActiveSupplier() {
FinanceFeeSourceEvent event = containerEvent("container-unrestricted-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有供应商",
null, 61L, "STORAGE", "仓储费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-unrestricted", 1);
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).requireActive(71L, null);
}
@Test
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
givenNewEvent();
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
@@ -100,7 +116,7 @@ class FinanceFeeSourceEventServiceTest {
void voidMarksExistingVersionWithoutDeletingHistory() {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
@@ -135,7 +151,7 @@ class FinanceFeeSourceEventServiceTest {
void rejectsSnapshotBelongingToAnotherCustomer() {
FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
@@ -147,7 +163,7 @@ class FinanceFeeSourceEventServiceTest {
void rejectsApprovedButUnpublishedNonVoidedFee() {
FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00"))));
assertThatThrownBy(() -> service.consume(event))
@@ -209,7 +225,7 @@ class FinanceFeeSourceEventServiceTest {
private void assertInvalidAmount(String eventId, BigDecimal amount) {
FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(1))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount)));
assertThatThrownBy(() -> service.consume(event))
@@ -139,7 +139,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":79,\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
@@ -318,8 +318,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.summary().direction()).isEqualTo("PAYABLE");
assertThat(result.summary().supplierId()).isEqualTo(20L);
assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00");
verify(supplierValidator).categoryFor("FBA_HEAD_FREIGHT", "头程运费");
verify(supplierValidator).requireActive(20L, "BOOKING");
verify(supplierValidator).requireActive(20L, null);
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L),
eq(new BigDecimal("100.00")), eq("CNY"));
}
@@ -541,8 +540,8 @@ class FinanceSettlementBehaviorTest {
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient,
FinanceSupplierValidator supplierValidator) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT f.fee_item_code"), anyLong(), anyLong()))
.thenReturn(List.of(Map.of("fee_item_code", "FBA_HEAD_FREIGHT", "fee_item_name", "头程运费")));
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
@@ -552,7 +551,6 @@ class FinanceSettlementBehaviorTest {
private static FinanceSupplierValidator supplierValidator() {
FinanceSupplierValidator supplierValidator = org.mockito.Mockito.mock(FinanceSupplierValidator.class);
org.mockito.Mockito.lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("BOOKING");
org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any()))
.thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
20L, "供应商20", "BOOKING", "ACTIVE"));
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(239);
assertThat(columnCount).isEqualTo(243);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -18,8 +18,12 @@ class FinanceSupplierValidatorTest {
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
@Test
void rejectsMissingOrInvalidExplicitFeeCategory() {
assertCode(() -> validator.requireActive(20L, null), "FIN_409");
void acceptsMissingCategoryAndRejectsInvalidExplicitCategory() {
var unrestricted = new WarehouseSupplierClient.SupplierSnapshot(
20L, "任意启用供应商", "BOOKING", "ACTIVE");
when(client.get("internal-token", 20L, true, null)).thenReturn(ApiResponse.success(unrestricted));
assertThat(validator.requireActive(20L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 20L, true, null);
assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409");
}