This commit is contained in:
2026-08-20 16:13:51 +08:00
parent 164d862588
commit 09f6e28c1f
10 changed files with 164 additions and 15 deletions
@@ -366,7 +366,20 @@ public class FinancePhase1OperationService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { 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); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; }
private static Long jsonLong(JsonNode node, String field) {
JsonNode value = node.path(field);
if (value.isIntegralNumber() && value.canConvertToLong()) {
long parsed = value.longValue();
return parsed > 0 ? parsed : null;
}
if (!value.isTextual()) return null;
try {
long parsed = Long.parseLong(value.textValue());
return parsed > 0 ? parsed : null;
} catch (NumberFormatException ignored) {
return null;
}
}
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
@@ -462,7 +462,9 @@ public class FinanceSettlementService {
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) throw error("FIN_409", "应付结算缺少供应商快照");
if ("PAYABLE".equals(text(row, "direction")) && supplierId == null) {
throw error("FIN_409", "应付结算缺少供应商快照");
}
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"),
@@ -594,7 +596,17 @@ public class FinanceSettlementService {
}
private static Long jsonLong(JsonNode node, String key) {
JsonNode value = node.path(key);
return value.isIntegralNumber() ? value.longValue() : null;
if (value.isIntegralNumber() && value.canConvertToLong()) {
long parsed = value.longValue();
return parsed > 0 ? parsed : null;
}
if (!value.isTextual()) return null;
try {
long parsed = Long.parseLong(value.textValue());
return parsed > 0 ? parsed : null;
} catch (NumberFormatException ignored) {
return null;
}
}
private static LocalDate jsonDate(JsonNode node, String key) {
String value = jsonText(node, key);
@@ -144,7 +144,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\",\"supplierId\":79,\"supplierName\":\"供应商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));
@@ -164,6 +164,7 @@ class FinancePhase1OperationBehaviorTest {
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierId()).isEqualTo(79L);
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementReque
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
@@ -286,6 +287,66 @@ class FinanceSettlementBehaviorTest {
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(88L), eq(10L), eq(501L));
}
@Test
void receivableSettlementLineDoesNotRequireSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "FBA_PRICING"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "RECEIVABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "头程运费"), Map.entry("amount", new BigDecimal("100.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "GENERATED")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary());
assertThat(line.direction()).isEqualTo("RECEIVABLE");
assertThat(line.supplierId()).isNull();
assertThat(line.customerName()).isEqualTo("客户10");
}
@Test
void payableSettlementLineStillRequiresSupplierSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("direction", "PAYABLE"),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("amount", new BigDecimal("100.00"))));
assertCode(() -> ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary()), "FIN_409");
}
@Test
void payableSettlementLineAcceptsStringEncodedSupplierIdSnapshot() {
Map<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
Map.entry("fee_source_id", 31L), Map.entry("source_type", "INBOUND_FEE"),
Map.entry("source_fee_id", 41L), Map.entry("source_fee_version", 1),
Map.entry("direction", "PAYABLE"), Map.entry("customer_id", 10L),
Map.entry("source_snapshot", "{\"customerName\":\"客户10\",\"supplierId\":\"2090349552523644929\",\"supplierName\":\"供应商A\",\"businessDate\":\"2026-08-11\"}"),
Map.entry("fee_item_name", "装车费"), Map.entry("amount", new BigDecimal("80.00")),
Map.entry("currency_code", "CNY"), Map.entry("review_status", "APPROVED"),
Map.entry("status", "AVAILABLE")));
row.put("order_id", null);
row.put("inbound_id", null);
row.put("container_id", null);
SettlementLine line = ReflectionTestUtils.invokeMethod(
service(org.mockito.Mockito.mock(JdbcTemplate.class), org.mockito.Mockito.mock(FinanceFileClient.class)),
"line", row, detail("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("80.00"), 0).summary());
assertThat(line.supplierId()).isEqualTo(2090349552523644929L);
assertThat(line.supplierName()).isEqualTo("供应商A");
}
@Test
void receivableCreationRejectsAlreadySettledStatement() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);