This commit is contained in:
2026-08-15 11:05:58 +08:00
parent c4282020b5
commit 557ab75319
50 changed files with 591 additions and 394 deletions
@@ -0,0 +1,20 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "warehouse-service", contextId = "financeWarehouseSupplierClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
public interface WarehouseSupplierClient {
@GetMapping("/{supplierId}")
ApiResponse<SupplierSnapshot> get(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId,
@RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam String category);
record SupplierSnapshot(long supplierId, String name, String category, String scope, String status) {}
}
@@ -28,6 +28,7 @@ public class FinanceFeeSourceEventService {
private final JdbcTemplate jdbc;
private final FirstlegFeeSourceClient firstleg;
private final ObjectMapper objectMapper;
private final FinanceSupplierValidator supplierValidator;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -126,6 +127,13 @@ public class FinanceFeeSourceEventService {
if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效");
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
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.partnerId() == null || value.partnerId() <= 0 || blank(value.partnerName())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
}
String category = supplierValidator.categoryFor(value.feeItemCode(), value.feeItemName());
supplierValidator.requireActive(value.partnerId(), category);
}
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
@@ -27,11 +27,14 @@ public class FinanceService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
}
public Map<String, Object> customerTerms(long tenantId) {
@@ -265,14 +268,23 @@ public class FinanceService {
if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId);
if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED");
if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0");
String supplierName = null;
if (request.supplierId() != null) {
if (request.supplierId() <= 0) throw bad("供应商ID无效");
String category = supplierValidator.categoryFor(request.feeItemCode(), request.feeItemName());
supplierName = supplierValidator.requireActive(request.supplierId(), category).name();
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
throw bad("供应商名称必须来自Warehouse供应商主数据");
}
validateVoucherFiles(tenantId, request.voucherFileIds());
if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次");
if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积");
if ("SINGLE_ORDER".equals(request.costType())) validateSingleOrder(tenantId, request.orderId());
validateContainerOrders(tenantId, request.containerId(), containerOrders);
String supplierNameSnapshot = supplierName;
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, request.supplierName()); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, supplierNameSnapshot); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder);
long costId = holder.getKey().longValue();
jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId);
if ("CONTAINER_SHARED".equals(request.costType())) {
@@ -46,11 +46,14 @@ public class FinanceSettlementService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
}
@Transactional
@@ -111,7 +114,7 @@ public class FinanceSettlementService {
List<Object> args = new ArrayList<>();
args.addAll(ids);
List<Map<String, Object>> sources = jdbc.queryForList(
"SELECT id,customer_id,direction,currency_code,status,amount,source_snapshot," +
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus')) review_status," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded')) settlement_included," +
"CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId'))," +
@@ -137,8 +140,11 @@ 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");
String supplierName = sources.stream().map(row -> text(row, "supplier_name"))
.filter(StringUtils::hasText).findFirst().orElse("供应商" + request.supplierId());
Set<String> supplierCategories = sources.stream()
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
.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 customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
LocalDate generatedDate = LocalDate.now();
@@ -215,7 +221,7 @@ public class FinanceSettlementService {
CreateSettlementPaymentRequest request) {
validatePaymentRequest(request);
Map<String, Object> settlement = one(
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version " +
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version,supplier_id " +
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST",
@@ -229,6 +235,8 @@ public class FinanceSettlementService {
if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突");
BigDecimal outstanding = decimal(settlement, "outstanding_amount");
if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额");
String supplierCategory = payableSupplierCategory(customerId, settlementId);
supplierValidator.requireActive(number(settlement, "supplier_id").longValue(), supplierCategory);
validateVoucherFiles(customerId, request.voucherFileIds());
insertPayment(customerId, settlementId, request, operatorId);
@@ -353,6 +361,18 @@ public class FinanceSettlementService {
voucherValidator.validate(customerId, fileIds, "付款凭证");
}
private String payableSupplierCategory(long customerId, long settlementId) {
Set<String> categories = jdbc.queryForList(
"SELECT f.fee_item_code,f.fee_item_name 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 -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
.collect(java.util.stream.Collectors.toSet());
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
return categories.iterator().next();
}
private Long reserve(long customerId, long operatorId, String operation, String key,
String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
@@ -417,10 +437,13 @@ public class FinanceSettlementService {
BigDecimal amount = decimal(row, "amount");
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
Long supplierId = jsonLong(snapshot, "supplierId");
if (supplierId == null) supplierId = jsonLong(snapshot, "partnerId");
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), firstText(jsonText(snapshot, "supplierName"), settlement.supplierName()),
supplierId, firstText(jsonText(snapshot, "supplierName"),
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
@@ -0,0 +1,66 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.WarehouseSupplierClient;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Locale;
import java.util.Set;
@Service
public class FinanceSupplierValidator {
private static final Set<String> CATEGORIES = Set.of("TRUCKING", "CUSTOMS", "BOOKING", "CERTIFICATE");
private final WarehouseSupplierClient client;
private final String internalToken;
public FinanceSupplierValidator(WarehouseSupplierClient client,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.client = client;
this.internalToken = internalToken;
}
public WarehouseSupplierClient.SupplierSnapshot requireActive(long supplierId, String category) {
String normalizedCategory = normalizeCategory(category);
try {
var response = client.get(internalToken, supplierId, true, normalizedCategory);
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())) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
}
return supplier;
} catch (FeignException exception) {
if (exception.status() == 400 || exception.status() == 404 || exception.status() == 409) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
}
throw new FinanceEventException("FIN_503", "供应商主数据服务暂不可用");
}
}
public String categoryFor(String feeItemCode, String feeItemName) {
String value = ((feeItemCode == null ? "" : feeItemCode) + " "
+ (feeItemName == null ? "" : feeItemName)).toUpperCase(Locale.ROOT);
if (containsAny(value, "TRUCK", "TRAILER", "DRAYAGE", "拖车", "卡车")) return "TRUCKING";
if (containsAny(value, "CUSTOMS", "CLEARANCE", "DECLARATION", "报关", "清关")) return "CUSTOMS";
if (containsAny(value, "CERTIFICATE", "CERTIFICATION", "CERT_", "认证", "证书")) return "CERTIFICATE";
if (containsAny(value, "BOOKING", "FREIGHT", "OCEAN", "SHIPPING", "订舱", "海运", "运费")) return "BOOKING";
throw new FinanceEventException("FIN_409", "费用来源缺少可验证的供应商类别");
}
private static boolean containsAny(String value, String... candidates) {
for (String candidate : candidates) if (value.contains(candidate)) return true;
return false;
}
private static String normalizeCategory(String category) {
String normalized = category == null ? "" : category.trim().toUpperCase(Locale.ROOT);
if (!CATEGORIES.contains(normalized)) {
throw new FinanceEventException("FIN_409", "费用来源供应商类别无效");
}
return normalized;
}
}
@@ -5,6 +5,7 @@ import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -30,11 +31,16 @@ class FinanceFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper);
private final FinanceSupplierValidator supplierValidator = mock(FinanceSupplierValidator.class);
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper, supplierValidator);
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("TRUCKING");
lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
71L, "现有供应商", "TRUCKING", "DOMESTIC", "ACTIVE"));
}
@Test
@@ -73,6 +79,8 @@ class FinanceFeeSourceEventServiceTest {
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).categoryFor("TRUCKING", "拖车费");
verify(supplierValidator).requireActive(71L, "TRUCKING");
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
@@ -1,6 +1,7 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
@@ -119,7 +120,7 @@ class FinanceServiceValidationTest {
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper(),
new FinanceVoucherValidator(null, ""));
new FinanceVoucherValidator(null, ""), mock(FinanceSupplierValidator.class));
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
@@ -275,12 +276,12 @@ class FinanceServiceValidationTest {
private static FinanceService service(JdbcTemplate jdbc, FinanceFileClient files) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(files, "internal-token"));
new FinanceVoucherValidator(files, "internal-token"), mock(FinanceSupplierValidator.class));
}
private static FinanceService service(JdbcTemplate jdbc) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceVoucherValidator.class));
mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class));
}
private static FinanceService.BillLine billLine(long sourceId, int version, String amount) {
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -38,6 +39,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
@@ -302,7 +304,8 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator);
SettlementDetail result = service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY"));
@@ -310,6 +313,8 @@ 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(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L),
eq(new BigDecimal("100.00")), eq("CNY"));
}
@@ -363,11 +368,13 @@ class FinanceSettlementBehaviorTest {
stubSettlementDetail(jdbc, after);
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSettlementService service = service(jdbc, fileClient);
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, fileClient, supplierValidator);
SettlementDetail result = service.createSettlementPayment(88L, 7L, "final-key", payment(new BigDecimal("60.00"), 1));
assertThat(result.summary().status()).isEqualTo("PAID");
verify(supplierValidator).requireActive(20L, "BOOKING");
assertThat(result.summary().outstandingAmount()).isZero();
}
@@ -522,8 +529,24 @@ class FinanceSettlementBehaviorTest {
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
return service(jdbc, fileClient, supplierValidator());
}
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", "头程运费")));
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"));
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator);
}
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", "INTERNATIONAL", "ACTIVE"));
return supplierValidator;
}
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
@@ -558,6 +581,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_included", "true"), Map.entry("supplier_id", supplierId),
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
}
@@ -0,0 +1,52 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.WarehouseSupplierClient;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSupplierValidator;
import feign.FeignException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceSupplierValidatorTest {
private final WarehouseSupplierClient client = mock(WarehouseSupplierClient.class);
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
@Test
void resolvesSupportedFeeCategories() {
assertThat(validator.categoryFor("TRUCKING", "拖车费")).isEqualTo("TRUCKING");
assertThat(validator.categoryFor("CUSTOMS_CLEARANCE", "报关费")).isEqualTo("CUSTOMS");
assertThat(validator.categoryFor("FBA_HEAD_FREIGHT", "头程运费")).isEqualTo("BOOKING");
assertThat(validator.categoryFor("CERTIFICATE_FEE", "证书费")).isEqualTo("CERTIFICATE");
assertCode(() -> validator.categoryFor("ACPT_UNKNOWN", "操作费"), "FIN_409");
}
@Test
void requiresActiveWarehouseSupplierWithMatchingCategory() {
var supplier = new WarehouseSupplierClient.SupplierSnapshot(20L, "正式供应商", "TRUCKING", "DOMESTIC", "ACTIVE");
when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(supplier));
assertThat(validator.requireActive(20L, "TRUCKING").name()).isEqualTo("正式供应商");
verify(client).get("internal-token", 20L, true, "TRUCKING");
}
@Test
void rejectsMismatchedOrUnavailableSupplier() {
var mismatched = new WarehouseSupplierClient.SupplierSnapshot(20L, "订舱供应商", "BOOKING", "INTERNATIONAL", "ACTIVE");
when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(mismatched));
assertCode(() -> validator.requireActive(20L, "TRUCKING"), "FIN_409");
when(client.get("internal-token", 21L, true, "BOOKING")).thenThrow(mock(FeignException.class));
assertCode(() -> validator.requireActive(21L, "BOOKING"), "FIN_503");
}
private static void assertCode(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, String code) {
assertThatThrownBy(call).isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo(code);
}
}