This commit is contained in:
2026-08-13 09:49:55 +08:00
parent f3366cb2fa
commit 95849f248f
69 changed files with 1224 additions and 221 deletions
@@ -6,7 +6,11 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRe
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementListEnvelope;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.service.FinanceSettlementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
import org.slf4j.MDC;
import org.springframework.security.access.AccessDeniedException;
@@ -19,9 +23,11 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@RestController
@@ -33,6 +39,24 @@ public class FinanceSettlementController {
this.service = service;
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:view')")
@Operation(summary = "查询正式应收结算单")
public SettlementListEnvelope receivableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return success(service.listSettlements("RECEIVABLE", customerId));
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:view')")
@Operation(summary = "查询正式应付结算单")
public SettlementListEnvelope payableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return success(service.listSettlements("PAYABLE", customerId));
}
@PostMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:create')")
public SettlementDetailEnvelope createReceivableSettlement(
@@ -82,6 +106,14 @@ public class FinanceSettlementController {
return new SettlementDetailEnvelope("200", "success", detail, requestId, traceId, Instant.now());
}
private static SettlementListEnvelope success(List<SettlementSummary> summaries) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return new SettlementListEnvelope("200", "success", summaries, requestId, traceId, Instant.now());
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
@@ -70,24 +70,6 @@ public class PlatformFinanceController {
return FinanceResponses.success(queryService.statements(customerId, false));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "结算单应收")
public ApiResponse<?> receivableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.receivableSettlements(customerId, false));
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
@Operation(summary = "结算单应付")
public ApiResponse<?> payableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')")
public ApiResponse<FinanceFileClient.Metadata> uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user,
@@ -42,6 +42,8 @@ public final class FinanceSettlementDtos {
public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
List<SettlementTransaction> transactions,
List<SettlementRevision> revisions) {}
public record SettlementListEnvelope(String code, String message, List<SettlementSummary> data,
String requestId, String traceId, Instant timestamp) {}
public record SettlementDetailEnvelope(String code, String message, SettlementDetail data,
String requestId, String traceId, Instant timestamp) {}
}
@@ -193,6 +193,27 @@ public class FinanceSettlementService {
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
}
public List<SettlementSummary> listSettlements(String direction, Long customerId) {
if (!Set.of("RECEIVABLE", "PAYABLE").contains(direction)) {
throw error("FIN_400", "结算单方向无效");
}
if (customerId != null && customerId <= 0) {
throw error("FIN_400", "客户范围无效");
}
String sql = "SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," +
"supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," +
"paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version " +
"FROM fin_settlement WHERE direction=?";
List<Object> args = new ArrayList<>();
args.add(direction);
if (customerId != null) {
sql += " AND tenant_id=?";
args.add(customerId);
}
sql += " ORDER BY generated_date DESC,id DESC";
return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList();
}
@Transactional
public SettlementDetail createSettlementPayment(long settlementId, long operatorId, String key,
CreateSettlementPaymentRequest request) {
@@ -1,6 +1,7 @@
package com.baiyue.finance;
import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.controller.FinanceSettlementController;
import io.swagger.v3.oas.annotations.Parameter;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -29,8 +30,8 @@ class FinancePlatformListContractTest {
Map<String, String> expected = Map.of(
"feePool", "hasAuthority('platform:finance:bill:view')",
"statements", "hasAuthority('platform:finance:bill:view')",
"receivableSettlements", "hasAuthority('platform:finance:bill:view')",
"payableSettlements", "hasAuthority('platform:finance:cost:view')");
"receivableSettlements", "hasAuthority('platform:finance:settlement-ar:view')",
"payableSettlements", "hasAuthority('platform:finance:settlement-ap:view')");
for (var entry : expected.entrySet()) {
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue());
}
@@ -46,7 +47,8 @@ class FinancePlatformListContractTest {
private static Method find(String name) throws Exception {
return switch (name) {
case "feePool" -> PlatformFinanceController.class.getMethod(name, Long.class, String.class);
default -> PlatformFinanceController.class.getMethod(name, Long.class);
case "statements" -> PlatformFinanceController.class.getMethod(name, Long.class);
default -> FinanceSettlementController.class.getMethod(name, Long.class);
};
}
}
@@ -4,11 +4,13 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import com.baiyue.finance.controller.ClientFinanceController;
import com.baiyue.finance.controller.FinanceSettlementController;
import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSettlementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,6 +33,7 @@ class FinanceReadApiBehaviorTest {
@Mock FinanceQueryService queryService;
@Mock FinanceFileClient fileClient;
@Mock FirstlegCostSnapshotClient snapshotClient;
@Mock FinanceSettlementService settlementService;
@Test
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
@@ -63,34 +66,36 @@ class FinanceReadApiBehaviorTest {
}
@Test
void fivePlatformPageMethodsUseTypedQueryFacade() {
void fivePlatformPageMethodsUseTheirTypedQueryFacades() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
var settlementController = new FinanceSettlementController(settlementService);
controller.customerAccount(11L);
controller.statements(11L);
controller.receivableSettlements(11L);
controller.payableSettlements(11L);
settlementController.receivableSettlements(11L);
settlementController.payableSettlements(11L);
verify(queryService).customerAccount(11L);
verify(queryService).statements(11L, false);
verify(queryService).receivableSettlements(11L, false);
verify(queryService).payableSettlements(11L);
verify(settlementService).listSettlements("RECEIVABLE", 11L);
verify(settlementService).listSettlements("PAYABLE", 11L);
}
@Test
void platformListsDefaultToAllCustomersAndKeepOptionalFilter() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
var settlementController = new FinanceSettlementController(settlementService);
controller.feePool(null, null);
controller.statements(null);
controller.receivableSettlements(null);
controller.payableSettlements(null);
settlementController.receivableSettlements(null);
settlementController.payableSettlements(null);
controller.statements(31L);
verify(queryService).feePool(null, null);
verify(queryService).statements(null, false);
verify(queryService).receivableSettlements(null, false);
verify(queryService).payableSettlements(null);
verify(settlementService).listSettlements("RECEIVABLE", null);
verify(settlementService).listSettlements("PAYABLE", null);
verify(queryService).statements(31L, false);
}
@@ -64,6 +64,44 @@ class FinanceSettlementBehaviorTest {
user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class);
}
@Test
void controllerListsFormalSettlementsByDirection() {
FinanceSettlementController controller = new FinanceSettlementController(controllerService);
SettlementSummary receivable = detail("RECEIVABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0).summary();
when(controllerService.listSettlements("RECEIVABLE", 10L)).thenReturn(List.of(receivable));
var response = controller.receivableSettlements(10L);
assertThat(response.code()).isEqualTo("200");
assertThat(response.data()).containsExactly(receivable);
verify(controllerService).listSettlements("RECEIVABLE", 10L);
}
@Test
void formalSettlementListUsesFinSettlementAndOptionalCustomerFilter() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
Map<String, Object> row = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 0);
when(jdbc.queryForList(anyString(), eq("PAYABLE"))).thenReturn(List.of(row));
when(jdbc.queryForList(anyString(), eq("PAYABLE"), eq(10L))).thenReturn(List.of(row));
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
var all = service.listSettlements("PAYABLE", null);
var scoped = service.listSettlements("PAYABLE", 10L);
assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L);
assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L);
ArgumentCaptor<String> allSql = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
verify(jdbc).queryForList(scopedSql.capture(), eq("PAYABLE"), eq(10L));
assertThat(allSql.getValue()).contains("FROM fin_settlement WHERE direction=?")
.doesNotContain("FROM fin_bill", "FROM fin_cost", "tenant_id=?");
assertThat(scopedSql.getValue()).contains("FROM fin_settlement WHERE direction=?", "tenant_id=?")
.doesNotContain("FROM fin_bill", "FROM fin_cost");
}
@Test
void arCreationRejectsNonCnyBeforeDatabaseAccess() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);