完善财务费用来源与柜级分摊

This commit is contained in:
2026-09-02 17:07:03 +08:00
parent 6cd477a037
commit 3bdaba2c60
34 changed files with 625 additions and 71 deletions
@@ -0,0 +1,83 @@
package com.baiyue.finance;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.finance.service.BusinessFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class BusinessFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class);
private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers);
@Test
void importsOverseasFeeWithoutInventingInboundReference() {
EventEnvelope event = eventWithoutInbound();
when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"),
eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"),
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0);
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event)).isEqualTo(901L);
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[3]).isEqualTo(10001L);
assertThat(values.getValue()[5]).isEqualTo("PO-US-001");
assertThat(values.getValue()[9]).isNull();
assertThat(values.getValue()[13]).isNull();
}
private EventEnvelope eventWithoutInbound() {
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG",
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
"OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001",
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"),
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1,
null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null,
node, payloadHash);
}
}
@@ -85,7 +85,7 @@ class CustomerAccountSummaryBehaviorTest {
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("os.direction='PAYABLE' AND os.status<>'VOIDED'")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin='FIRSTLEG_DIRECT_PAID'", "ps.paid_amount")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "ps.paid_amount")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
.contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
@@ -59,7 +59,7 @@ class FinanceCustomerAccountServiceTest {
String all = String.join("\n", sql.getAllValues());
assertThat(all)
.contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'")
.contains("cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'")
.contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID') AND status='CONFIRMED'")
.contains("direction='PAYABLE' AND status<>'VOIDED'")
.contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line")
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'")
@@ -61,7 +61,7 @@ class FinanceFeeSourceEventServiceTest {
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).container("test-token", 41, 31, 1);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L));
}
@Test
@@ -143,7 +143,7 @@ class FinanceFeeSourceEventServiceTest {
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));
verify(jdbc).update(contains("status='SUPERSEDED'"), eq("INBOUND_FEE"), eq(31L));
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@@ -154,7 +154,7 @@ class FinanceFeeSourceEventServiceTest {
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"),
eq(Integer.class), eq(31L))).thenReturn(1);
eq(Integer.class), anyString(), eq(31L))).thenReturn(1);
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
@@ -168,7 +168,7 @@ class FinanceFeeSourceEventServiceTest {
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), 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)))
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
assertThat(service.consume(event)).isEqualTo(88L);
@@ -183,7 +183,7 @@ class FinanceFeeSourceEventServiceTest {
.thenReturn(List.of());
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)))
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"),
eq(Integer.class), eq(88L))).thenReturn(1);
@@ -253,8 +253,8 @@ class FinanceFeeSourceEventServiceTest {
private void givenNewEvent() {
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), anyInt())).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq(31L), anyInt())).thenReturn(101L);
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), anyString(), eq(31L), anyInt())).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L);
}
private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) {
@@ -16,7 +16,7 @@ class FinanceFeeSourceMigrationContractTest {
"source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code");
assertThat(sql).contains("settlement_mode", "paid_at", "payment_method",
"payment_reference_no", "payment_voucher_file_ids");
assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "source_fee_version",
assertThat(sql).contains("cost_origin", "FIRSTLEG_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "warehouse_id", "source_fee_version",
"uk_fin_cost_source_version", "Finance直接已付成本字段不完整");
assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
assertThat(sql).contains("finance_fee_source_assert_schema");
@@ -107,7 +107,7 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.traceId()).isNotBlank();
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
assertThat(jdbc.accountListSql)
.contains("ar.direction='RECEIVABLE'", "cost_origin='FIRSTLEG_DIRECT_PAID'", "FROM fin_settlement_line sl")
.contains("ar.direction='RECEIVABLE'", "cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "FROM fin_settlement_line sl")
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'");
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
}
@@ -123,7 +123,7 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.data()).isEmpty();
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L);
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'"));
.contains("cost_origin IN ('FIRSTLEG_DIRECT_PAID','BUSINESS_DIRECT_PAID')", "status='CONFIRMED'"));
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
}
@@ -240,9 +240,9 @@ class FinancePhase1OperationBehaviorTest {
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1);
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
.isInstanceOf(FinanceEventException.class).hasMessageContaining("业务费用必须回到对应业务费用明细调整");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="),
(Object[]) any());
}
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(257);
assertThat(columnCount).isEqualTo(265);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -56,7 +56,7 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
assertThat(sql).contains("仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款");
assertThat(sql).contains("来源直接已付且CONFIRMED计入已线下支付供应商款");
assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
}