优化
This commit is contained in:
@@ -16,8 +16,10 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */
|
||||
@Service
|
||||
@@ -116,6 +118,7 @@ public class FinanceService {
|
||||
if (replay != null) return bill(tenantId, replay);
|
||||
if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细");
|
||||
request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); });
|
||||
validateBillSources(tenantId, request.lines());
|
||||
BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (total.signum() < 0) throw bad("账单金额不能为负数");
|
||||
Object credit = customerTerms(tenantId).get("credit_days");
|
||||
@@ -139,6 +142,49 @@ public class FinanceService {
|
||||
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId);
|
||||
}
|
||||
|
||||
/** Initial statements may only snapshot approved, included Finance sources; the client cannot choose an amount. */
|
||||
private void validateBillSources(long tenantId, List<BillLine> lines) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (BillLine line : lines) {
|
||||
if (!seen.add(line.sourceId() + ":" + line.sourceVersion())) throw bad("同一费用来源版本不能重复加入账单");
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT customer_id,source_fee_version,direction,currency_code,amount,status,review_status,settlement_included "
|
||||
+ "FROM fin_fee_source WHERE id=? FOR UPDATE", line.sourceId());
|
||||
if (rows.size() != 1) throw bad("费用来源不存在");
|
||||
Map<String, Object> source = rows.get(0);
|
||||
if (number(source, "customer_id") != tenantId
|
||||
|| number(source, "source_fee_version") != line.sourceVersion()
|
||||
|| !"RECEIVABLE".equals(source.get("direction"))
|
||||
|| !"CNY".equals(source.get("currency_code"))
|
||||
|| !"AVAILABLE".equals(source.get("status"))
|
||||
|| !"APPROVED".equals(source.get("review_status"))
|
||||
|| !truth(source.get("settlement_included"))) {
|
||||
throw bad("费用来源未审核、未参与结算或不属于当前客户");
|
||||
}
|
||||
BigDecimal sourceAmount = decimal(source, "amount");
|
||||
if (sourceAmount.compareTo(line.amount()) != 0) throw bad("账单金额必须等于费用来源金额");
|
||||
Integer used = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id "
|
||||
+ "WHERE l.tenant_id=? AND l.source_id=? AND l.source_version=? AND b.status<>'VOIDED'",
|
||||
Integer.class, tenantId, line.sourceId(), line.sourceVersion());
|
||||
if (used != null && used > 0) throw bad("费用来源已被有效账单占用");
|
||||
}
|
||||
}
|
||||
|
||||
private static long number(Map<String, Object> row, String key) {
|
||||
Object value = row.get(key);
|
||||
return value instanceof Number n ? n.longValue() : Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(Map<String, Object> row, String key) {
|
||||
Object value = row.get(key);
|
||||
return value instanceof BigDecimal decimal ? decimal : new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
|
||||
private static boolean truth(Object value) {
|
||||
return Boolean.TRUE.equals(value) || "1".equals(String.valueOf(value)) || "true".equalsIgnoreCase(String.valueOf(value));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) {
|
||||
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion));
|
||||
|
||||
@@ -24,6 +24,9 @@ class FinanceContractTest {
|
||||
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
|
||||
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
|
||||
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
|
||||
assertTrue(service.contains("validateBillSources(tenantId, request.lines())"));
|
||||
assertTrue(service.contains("账单金额必须等于费用来源金额"));
|
||||
assertTrue(service.contains("source_fee_version") && service.contains("settlement_included"));
|
||||
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
|
||||
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
|
||||
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||
|
||||
@@ -9,10 +9,13 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -21,6 +24,45 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FinanceServiceValidationTest {
|
||||
@Test
|
||||
void acceptsOnlyApprovedIncludedReceivableSourceAtItsRecordedAmount() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
|
||||
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class),
|
||||
eq(9L), eq(81L), eq(3))).thenReturn(0);
|
||||
FinanceService service = service(jdbc);
|
||||
|
||||
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "validateBillSources", 9L,
|
||||
List.of(billLine(81L, 3, "125.50"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsClientAmountThatDiffersFromFinanceSourceSnapshot() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
|
||||
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
|
||||
FinanceService service = service(jdbc);
|
||||
|
||||
BizException error = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
|
||||
"validateBillSources", 9L, List.of(billLine(81L, 3, "99.00"))));
|
||||
|
||||
assertEquals("账单金额必须等于费用来源金额", error.getMessage());
|
||||
verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"),
|
||||
eq(Integer.class), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSourceThatIsNotApprovedAndIncludedForSettlement() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
|
||||
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "PENDING", false, "125.50")));
|
||||
FinanceService service = service(jdbc);
|
||||
|
||||
assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
|
||||
"validateBillSources", 9L, List.of(billLine(81L, 3, "125.50"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() {
|
||||
var rows = List.of(
|
||||
@@ -240,4 +282,16 @@ class FinanceServiceValidationTest {
|
||||
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceVoucherValidator.class));
|
||||
}
|
||||
|
||||
private static FinanceService.BillLine billLine(long sourceId, int version, String amount) {
|
||||
return new FinanceService.BillLine("FIRSTLEG_FEE", sourceId, version, null, 31L, 41L,
|
||||
"FREIGHT", "运费", new BigDecimal(amount));
|
||||
}
|
||||
|
||||
private static Map<String, Object> source(long customerId, int version, String status,
|
||||
String reviewStatus, boolean included, String amount) {
|
||||
return Map.of("customer_id", customerId, "source_fee_version", version,
|
||||
"direction", "RECEIVABLE", "currency_code", "CNY", "amount", new BigDecimal(amount),
|
||||
"status", status, "review_status", reviewStatus, "settlement_included", included);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user