This commit is contained in:
2026-08-19 09:14:30 +08:00
parent 8845a66749
commit ff1d330c2d
91 changed files with 1538 additions and 523 deletions
@@ -5,6 +5,7 @@ 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;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -15,20 +16,23 @@ import java.time.LocalDateTime;
public interface FirstlegFeeSourceClient {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long inboundId, @PathVariable long feeId);
@PathVariable long inboundId, @PathVariable long feeId,
@RequestParam int sourceVersion);
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long containerId, @PathVariable long feeId);
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam int sourceVersion);
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
String customerName, Long partnerId, String partnerName,
String customerName, Long supplierId, String supplierName,
String supplierCategory,
Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
String publicationStatus, boolean settlementIncluded,
String publicationStatus,
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
String chargeUnit, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
@@ -4,8 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -16,9 +19,12 @@ import org.springframework.web.bind.annotation.*;
public class ClientFinanceController {
private final FinanceService service;
private final FinanceQueryService queryService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
private final ReconciliationStatementService reconciliationStatementService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService,
ReconciliationStatementService reconciliationStatementService) {
this.service = service;
this.queryService = queryService;
this.reconciliationStatementService = reconciliationStatementService;
}
@GetMapping("/bills")
@@ -45,6 +51,17 @@ public class ClientFinanceController {
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
}
@PostMapping("/statements/{statementId}/confirm")
@PreAuthorize("hasAuthority('merchant:finance:statement:confirm')")
@Operation(summary = "客户确认本人已出账对账单")
public ApiResponse<?> confirmStatement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long statementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CustomerConfirmationRequest request) {
return FinanceResponses.success(reconciliationStatementService.confirmForCustomer(
user.tenantId(), user.userId(), statementId, idempotencyKey, request));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询结算应收")
@@ -3,7 +3,8 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -64,13 +65,12 @@ public class FinancePhase1OperationController {
@RequestParam(required = false) String direction,
@RequestParam(required = false) String status,
@RequestParam(required = false) String reviewStatus,
@RequestParam(required = false) Boolean settlementIncluded,
@RequestParam(required = false) Boolean statementGenerated,
@RequestParam(required = false) LocalDate businessDateFrom,
@RequestParam(required = false) LocalDate businessDateTo,
@RequestParam(required = false) String keyword) {
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
settlementIncluded, statementGenerated, businessDateFrom, businessDateTo, keyword);
statementGenerated, businessDateFrom, businessDateTo, keyword);
}
@GetMapping("/fee-sources/{feeSourceId}")
@@ -88,12 +88,39 @@ public class FinancePhase1OperationController {
return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PutMapping("/fee-sources/{feeSourceId}/settlement-inclusion")
@PreAuthorize("hasAuthority('platform:finance:fee-source:settlement')")
public ApiResponse<?> updateFeeSourceSettlementInclusion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SettlementInclusionRequest request) {
return FinanceResponses.success(service.updateFeeSourceSettlementInclusion(feeSourceId, user.userId(), key, request));
@PutMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> editFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, false));
}
@PostMapping("/fee-sources/{feeSourceId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> submitFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.submitFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PostMapping("/fee-sources/{feeSourceId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> adjustFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, true));
}
@PostMapping("/fee-sources/{feeSourceId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> voidFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.voidFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
}
@@ -105,22 +105,6 @@ public class PlatformFinanceController {
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
}
@PostMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
}
@PostMapping("/bills/{billId}/issue")
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@@ -228,7 +212,6 @@ public class PlatformFinanceController {
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
public record TermsRequest(String customerName, Integer creditDays) {}
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {}
public record VersionRequest(int expectedVersion) {}
public record ReasonRequest(int expectedVersion, String reason) {}
@@ -0,0 +1,42 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.service.ReceivableStatisticsService;
import com.baiyue.finance.service.FinanceEventException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.YearMonth;
@Tag(name = "应收款统计")
@RestController
@RequestMapping("/api/platform/finance/v1/receivable-statistics")
public class ReceivableStatisticsController {
private final ReceivableStatisticsService service;
public ReceivableStatisticsController(ReceivableStatisticsService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:finance:receivable-statistics:list')")
@Operation(summary = "按生成月份和客户汇总CNY正式应收结算单")
public ApiResponse<ReceivableStatisticsResponse> query(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM") String month,
@RequestParam(required = false) Long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
try {
return FinanceResponses.success(service.query(YearMonth.parse(month), customerId, page, pageSize));
} catch (java.time.format.DateTimeParseException exception) {
throw new FinanceEventException("FIN-STAT-400", "统计月份格式必须为yyyy-MM");
}
}
}
@@ -44,7 +44,7 @@ public final class FinancePhase1Dtos {
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
@@ -54,15 +54,16 @@ public final class FinancePhase1Dtos {
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated,
String reviewReason, String settlementReason, String sourceHash,
String reviewReason, String sourceHash,
int operationVersion, List<OperationLog> operationLogs) {}
public record OperationLog(LocalDateTime occurredAt, String action, String operatorName,
String result, String remark) {}
public record ReviewRequest(String action, String reason, int expectedVersion) {}
public record SettlementInclusionRequest(boolean included, String reason, int expectedVersion) {}
public record FeeSourceEditRequest(BigDecimal amount, String reason, int expectedVersion) {}
public record FeeSourceActionRequest(String reason, int expectedVersion) {}
private static String correlation(String first, String second) {
String value = MDC.get(first);
@@ -69,6 +69,10 @@ public final class FinanceReadModels {
String currency,
String status,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
long createdBy,
String createdByName,
LocalDateTime createdAt,
@@ -99,6 +103,10 @@ public final class FinanceReadModels {
LocalDateTime reviewedAt,
LocalDateTime issuedAt,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
String remark,
long createdBy,
String createdByName,
@@ -35,7 +35,7 @@ public final class FinanceSettlementDtos {
String inboundCode, Long containerId, String containerNo,
LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, boolean settlementIncluded, String sourceStatus,
String reviewStatus, String sourceStatus,
boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
@@ -0,0 +1,19 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
public final class ReceivableStatisticsDtos {
private ReceivableStatisticsDtos() {}
public record ReceivableStatisticsRow(long customerId, String customerName, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding) {}
public record ReceivableStatisticsResponse(YearMonth statisticsMonth, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding, long totalCustomers,
int page, int pageSize,
List<ReceivableStatisticsRow> records) {}
}
@@ -15,6 +15,12 @@ public final class ReconciliationStatementDtos {
public record ReconciliationActionRequest(String action, String reason, int expectedVersion,
List<StatementAdjustmentLineRequest> adjustmentLines) {}
public record CustomerConfirmationRequest(int expectedVersion) {}
public record CustomerConfirmationResult(long statementId, String confirmationStatus,
long confirmedBy, String confirmedByName,
LocalDateTime confirmedAt, int version) {}
public record StatementAdjustmentLineRequest(long statementLineId, BigDecimal adjustedAmount,
String reason) {}
@@ -23,6 +29,8 @@ public final class ReconciliationStatementDtos {
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
long createdBy, String createdByName, LocalDateTime createdAt,
long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark,
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
@@ -0,0 +1,53 @@
package com.baiyue.finance.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
@Component
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private final Clock clock;
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
}
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
jdbc.update(
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
}
}
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
@@ -25,6 +26,7 @@ import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
@@ -45,6 +47,7 @@ public class FinanceExportProviderService {
public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL";
public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST";
public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL";
public static final String RECEIVABLE_STATISTICS = "FINANCE_RECEIVABLE_STATISTICS";
private static final int SCHEMA_VERSION = 1;
private static final int MAX_BATCH_SIZE = 500;
@@ -52,9 +55,10 @@ public class FinanceExportProviderService {
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
private static final Set<String> TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER,
FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST,
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL);
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL,
RECEIVABLE_STATISTICS);
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
private static final Set<String> LEDGER_TYPES = Set.of("FEE_INCLUDED", "FEE_EXCLUDED", "RECONCILIATION_CREATED",
private static final Set<String> LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED",
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
@@ -76,7 +80,7 @@ public class FinanceExportProviderService {
column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14),
column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14),
column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16),
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14), column("settlementIncluded", "参与结算", ExportColumnType.BOOLEAN, 12),
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14),
column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16));
private static final List<ExportColumn> STATEMENT_COLUMNS = List.of(
column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
@@ -101,12 +105,20 @@ public class FinanceExportProviderService {
column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16));
private static final List<ExportColumn> RECEIVABLE_STATISTICS_COLUMNS = List.of(
column("statisticsMonth", "统计月份", ExportColumnType.STRING, 14),
column("customerName", "应收单位", ExportColumnType.STRING, 28),
column("currency", "币种", ExportColumnType.STRING, 10),
column("totalReceivable", "应收总额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalReceived", "已收合计(CNY)", ExportColumnType.DECIMAL, 18),
column("totalOutstanding", "余额未收(CNY)", ExportColumnType.DECIMAL, 18));
private final FinanceCustomerAccountService accountService;
private final FinancePhase1OperationService operationService;
private final FinanceQueryService queryService;
private final ReconciliationStatementService reconciliationService;
private final FinanceSettlementService settlementService;
private final ReceivableStatisticsService receivableStatisticsService;
private final ObjectMapper objectMapper;
private final String internalServiceToken;
private byte[] snapshotSigningKey;
@@ -116,6 +128,7 @@ public class FinanceExportProviderService {
FinanceQueryService queryService,
ReconciliationStatementService reconciliationService,
FinanceSettlementService settlementService,
ReceivableStatisticsService receivableStatisticsService,
ObjectMapper objectMapper,
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
this.accountService = accountService;
@@ -123,6 +136,7 @@ public class FinanceExportProviderService {
this.queryService = queryService;
this.reconciliationService = reconciliationService;
this.settlementService = settlementService;
this.receivableStatisticsService = receivableStatisticsService;
this.objectMapper = objectMapper;
this.internalServiceToken = internalServiceToken;
}
@@ -168,6 +182,7 @@ public class FinanceExportProviderService {
case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p);
case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p);
case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p);
case RECEIVABLE_STATISTICS -> receivableStatisticsRows(p);
default -> throw invalid("Unsupported Finance export type");
};
int from = Math.min(offset, all.size());
@@ -175,12 +190,18 @@ public class FinanceExportProviderService {
return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size());
}
private List<Map<String, Object>> receivableStatisticsRows(Map<String, Object> parameters) {
YearMonth month = statisticsMonth(parameters);
Long customerId = longValue(parameters, "customerId", false);
return receivableStatisticsService.exportRows(month, customerId).stream()
.map(value -> receivableStatisticsRow(month, value)).toList();
}
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String direction = text(p, "direction");
String status = text(p, "status");
String reviewStatus = text(p, "reviewStatus");
Boolean included = bool(p, "settlementIncluded");
Boolean generated = bool(p, "statementGenerated");
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
String keyword = lower(text(p, "keyword"));
@@ -189,13 +210,11 @@ public class FinanceExportProviderService {
FeeSourceDetail detail = detail(item);
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
String actualReview = detail == null ? null : detail.reviewStatus();
Boolean actualIncluded = detail == null ? null : detail.settlementIncluded();
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
LocalDate businessDate = detail == null || detail.businessDate() == null
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
if (included != null && !included.equals(actualIncluded)) continue;
if (generated != null && !generated.equals(actualGenerated)) continue;
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
@@ -298,7 +317,7 @@ public class FinanceExportProviderService {
if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid");
String review = text(p, "reviewStatus");
if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid");
bool(p, "settlementIncluded"); bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status");
}
if (RECONCILIATION_STATEMENT_LIST.equals(type)) {
@@ -313,6 +332,7 @@ public class FinanceExportProviderService {
max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName");
}
}
if (RECEIVABLE_STATISTICS.equals(type)) statisticsMonth(p);
}
private String signSnapshot(String type, ValidatedRequest request) {
@@ -358,7 +378,7 @@ public class FinanceExportProviderService {
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
"reviewStatus", detail == null ? null : detail.reviewStatus(), "settlementIncluded", detail == null ? null : detail.settlementIncluded(),
"reviewStatus", detail == null ? null : detail.reviewStatus(),
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
}
private Map<String, Object> statementRow(ReconciliationDetail value) {
@@ -379,6 +399,11 @@ public class FinanceExportProviderService {
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", summary.status());
}
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
"currency", value.currency(), "totalReceivable", value.totalReceivable(),
"totalReceived", value.totalReceived(), "totalOutstanding", value.totalOutstanding());
}
private static List<ExportColumn> columns(String type) {
return switch (type) {
@@ -388,6 +413,7 @@ public class FinanceExportProviderService {
case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS;
case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS;
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS;
case RECEIVABLE_STATISTICS -> RECEIVABLE_STATISTICS_COLUMNS;
default -> throw invalid("Unsupported Finance export type");
};
}
@@ -395,11 +421,12 @@ public class FinanceExportProviderService {
return switch (type) {
case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status");
case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate");
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "settlementIncluded", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo");
case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo");
case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName");
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId");
case RECEIVABLE_STATISTICS -> Set.of("month", "customerId");
default -> throw invalid("Unsupported Finance export type");
};
}
@@ -452,6 +479,12 @@ public class FinanceExportProviderService {
try { return LocalDate.parse(value); }
catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); }
}
private static YearMonth statisticsMonth(Map<String, Object> values) {
String value = text(values, "month");
if (value == null) throw invalid("month is required");
try { return YearMonth.parse(value); }
catch (RuntimeException exception) { throw invalid("month must use YYYY-MM"); }
}
private static void range(Map<String, Object> values, String fromKey, String toKey) {
LocalDate from = date(values, fromKey), to = date(values, toKey);
if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey);
@@ -94,8 +94,10 @@ public class FinanceFeeSourceEventService {
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
FinanceFeeSourceEvent.Payload payload = event.payload();
var response = payload.inboundId() != null
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"),
positive(payload.feeId(), "feeId"), payload.feeVersion())
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"),
positive(payload.feeId(), "feeId"), payload.feeVersion());
if (response == null || response.data() == null || !"200".equals(response.code())) {
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
}
@@ -120,7 +122,7 @@ public class FinanceFeeSourceEventService {
long feeId = positive(event.payload().feeId(), "feeId");
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
if (!"APPROVED".equals(value.reviewStatus())) throw error("FIN-EVT-009", "费用未审核通过");
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
throw error("FIN-EVT-015", "费用来源尚未发布");
}
@@ -128,11 +130,17 @@ public class FinanceFeeSourceEventService {
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())) {
if (value.supplierId() == null || value.supplierId() <= 0
|| blank(value.supplierName()) || blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
}
String category = supplierValidator.categoryFor(value.feeItemCode(), value.feeItemName());
supplierValidator.requireActive(value.partnerId(), category);
var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory());
if (!value.supplierName().equals(supplier.name())) {
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
}
} else if (value.supplierId() != null || !blank(value.supplierName())
|| !blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
}
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", "柜子归属不一致");
@@ -7,7 +7,8 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
@@ -89,7 +90,7 @@ public class FinancePhase1OperationService {
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
String direction, String sourceStatus,
String reviewStatus, Boolean settlementIncluded,
String reviewStatus,
Boolean statementGenerated, LocalDate businessDateFrom,
LocalDate businessDateTo, String keyword) {
page = page(page); pageSize = pageSize(pageSize);
@@ -110,7 +111,6 @@ public class FinancePhase1OperationService {
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
if (settlementIncluded != null) { where.append(" AND f.settlement_included=?"); args.add(settlementIncluded); }
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
@@ -120,9 +120,8 @@ public class FinancePhase1OperationService {
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.partnerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
for (int i = 0; i < 7; i++) args.add(normalizedKeyword);
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
}
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
List<Object> queryArgs = new ArrayList<>(args);
@@ -148,7 +147,7 @@ public class FinancePhase1OperationService {
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request);
if (replay != null) return getFinanceFeeSource(replay);
String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED";
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status='PENDING'",
review, request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
@@ -158,19 +157,68 @@ public class FinancePhase1OperationService {
}
@Transactional
public FeeSourceDetail updateFeeSourceSettlementInclusion(long feeSourceId, long operatorId, String key, SettlementInclusionRequest request) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "参与结算请求无效");
public FeeSourceDetail editFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceEditRequest request, boolean adjustment) {
if (request == null || request.expectedVersion() < 0 || request.amount() == null
|| request.amount().signum() <= 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, "PUT", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/settlement-inclusion", request);
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
Long replay = reserve(before.customerId(), operatorId, operation, key,
adjustment ? "POST" : "PUT", path, request);
if (replay != null) return getFinanceFeeSource(replay);
if (request.included() && !"APPROVED".equals(before.reviewStatus())) throw error("FIN-FEE-409", "只有审核通过的费用来源可参与结算");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成对账单的费用来源不可修改参与结算状态");
int changed = jdbc.update("UPDATE fin_fee_source SET settlement_included=?,settlement_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
request.included(), request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许修改参与结算状态");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
}
int changed = jdbc.update("UPDATE fin_fee_source SET amount=?,review_status='DRAFT',review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
request.amount(), feeSourceId, request.expectedVersion(), requiredStatus);
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许编辑或调整");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, request.included() ? "FEE_INCLUDED" : "FEE_EXCLUDED", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, feeSourceId);
audit(after.customerId(), feeSourceId, adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@Transactional
public FeeSourceDetail submitFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_SUBMIT",
"PENDING", List.of("DRAFT", "REJECTED"), false);
}
@Transactional
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
}
private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request, String operation,
String target, List<String> allowedReviews,
boolean voidSource) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, operation, key, "POST",
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
int changed = voidSource
? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
feeSourceId, request.expectedVersion(), before.reviewStatus())
: jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
target, feeSourceId, request.expectedVersion(), before.reviewStatus());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本已变化");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, operation, request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@@ -183,9 +231,9 @@ public class FinancePhase1OperationService {
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
rs.getString("review_reason"), rs.getString("settlement_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
@@ -198,11 +246,11 @@ public class FinancePhase1OperationService {
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")), nullableLong(rs, "order_id"),
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, unitPrice, amount, rs.getString("currency_code"),
defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
@@ -72,7 +72,7 @@ public class FinanceQueryService {
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
"AND f.review_status='APPROVED' AND f.amount>0 " +
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
@@ -126,13 +126,15 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
@@ -140,7 +142,7 @@ public class FinanceQueryService {
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
validateOptionalCustomer(customerId);
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
"COALESCE(p.credit_days,30) credit_days," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," +
@@ -160,7 +162,10 @@ public class FinanceQueryService {
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"),
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"), rs.getString("remark"),
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getString("remark"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
@@ -168,10 +173,12 @@ public class FinanceQueryService {
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
@@ -16,10 +16,8 @@ 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
@@ -82,13 +80,6 @@ public class FinanceService {
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
}
@Transactional
public Map<String, Object> issueBillById(long operatorId, long billId, int expectedVersion, String idempotencyKey) {
Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
if (tenantId == null) throw bad("账单不存在");
return issueBill(tenantId, operatorId, billId, expectedVersion, idempotencyKey);
}
public List<Map<String, Object>> listReceipts(Long tenantId) {
String sql = "SELECT r.id,r.tenant_id,r.receipt_no,r.received_amount,r.received_at,r.payment_method,r.voucher_no,r.voucher_file_ids,r.status,rb.bill_id,rb.applied_amount FROM fin_receipt r LEFT JOIN fin_receipt_bill rb ON rb.receipt_id=r.id";
return tenantId == null ? jdbc.queryForList(sql + " ORDER BY r.received_at DESC,r.id DESC") : jdbc.queryForList(sql + " WHERE r.tenant_id=? ORDER BY r.received_at DESC,r.id DESC", tenantId);
@@ -114,91 +105,6 @@ public class FinanceService {
return customerTerms(tenantId);
}
@Transactional
public Map<String, Object> createBill(long tenantId, long operatorId, String customerName, BillRequest request,
String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey, request);
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");
if (credit == null) credit = customerTerms(tenantId).get("creditDays");
int days = credit == null ? 30 : ((Number) credit).intValue();
LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate();
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("BILL")); ps.setLong(3, request.subjectId()); ps.setString(4, customerName);
ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total);
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setString(12, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(13, operatorId); ps.setString(14, FinanceOperatorSnapshot.currentName(operatorId)); return ps;
}, holder);
long billId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo("BILL", billId), billId);
for (BillLine line : request.lines()) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
tenantId, billId, line.sourceType(), line.sourceId(), line.sourceVersion(), line.sourceNo(), line.orderId(), line.inboundId(), line.feeItemCode(), line.feeItemName(), line.amount());
}
complete(tenantId, operatorId, "BILL_CREATE", idempotencyKey, billId);
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));
if (replay != null) return bill(tenantId, replay);
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?",
LocalDateTime.now(), operatorId, operatorId, FinanceOperatorSnapshot.currentName(operatorId), billId, tenantId, expectedVersion);
if (updated != 1) throw bad("账单不存在或已出账");
complete(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, billId);
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId);
}
@Transactional
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request);
@@ -271,8 +177,7 @@ public class FinanceService {
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();
throw bad("人工成本缺少显式供应商类别,无法校验供应商");
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
throw bad("供应商名称必须来自Warehouse供应商主数据");
}
@@ -393,8 +298,6 @@ public class FinanceService {
private static String requestPath(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
case "BILL_CREATE" -> "/api/platform/finance/v1/bills";
case "BILL_ISSUE" -> "/api/platform/finance/v1/bills/{billId}/issue";
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
@@ -437,8 +340,6 @@ public class FinanceService {
private void replaceDraftLines(long tenantId, long billId, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); }
private void insertLines(long tenantId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); }
public record BillRequest(String billNo, long subjectId, LocalDate billDate, String remark, List<BillLine> lines) {}
public record BillLine(String sourceType, long sourceId, int sourceVersion, String sourceNo, Long orderId, Long inboundId, String feeItemCode, String feeItemName, BigDecimal amount) {}
public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
public record ReceiptAllocation(long billId, BigDecimal amount) {}
public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List<String> voucherFileIds, String remark) {}
@@ -16,6 +16,7 @@ import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -63,13 +64,22 @@ public class FinanceSettlementService {
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
private final FinanceDocumentNumberGenerator documentNumbers;
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
}
@Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
this.documentNumbers = documentNumbers;
}
@Transactional
@@ -101,7 +111,7 @@ public class FinanceSettlementService {
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNo("AR"), "RECEIVABLE",
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE",
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
generatedDate, date(bill, "due_date"), operatorId);
@@ -131,12 +141,10 @@ public class FinanceSettlementService {
args.addAll(ids);
List<Map<String, Object>> sources = jdbc.queryForList(
"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'))," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerId'))) AS UNSIGNED) supplier_id," +
"COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName'))," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerName'))) supplier_name " +
"review_status," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierCategory')) supplier_category " +
"FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray());
if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) {
throw error("FIN_404", "费用来源不存在或不属于客户");
@@ -145,8 +153,8 @@ public class FinanceSettlementService {
Long supplierId = nullableNumber(source, "supplier_id");
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|| !"APPROVED".equals(text(source, "review_status")) || !truth(source.get("settlement_included"))) {
throw error("FIN_409", "费用来源未审核、未参与结算或状态不可用");
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
}
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
Integer used = jdbc.queryForObject(
@@ -156,15 +164,17 @@ 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");
Set<String> supplierCategories = sources.stream()
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
if (sources.stream().anyMatch(row -> !StringUtils.hasText(text(row, "supplier_category")))) {
throw error("FIN_409", "应付费用来源缺少供应商类别快照");
}
Set<String> supplierCategories = sources.stream().map(row -> text(row, "supplier_category"))
.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();
long settlementId = insertSettlement(customerId, documentNo("AP"), "PAYABLE", customerName,
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
CNY, total, generatedDate, request.dueDate(), operatorId);
for (Map<String, Object> source : sources) {
@@ -185,7 +195,7 @@ public class FinanceSettlementService {
SettlementSummary summary = summary(row);
List<SettlementLine> lines = jdbc.queryForList(
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
@@ -330,7 +340,7 @@ public class FinanceSettlementService {
jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," +
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
customerId, settlementId, documentNo("PAY"), request.transactionDate(), request.amount(), CNY,
customerId, settlementId, transactionNo(), request.transactionDate(), request.amount(), CNY,
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()),
operatorId, FinanceOperatorSnapshot.currentName(operatorId));
}
@@ -375,11 +385,11 @@ public class FinanceSettlementService {
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 " +
"SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category 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")))
.map(row -> text(row, "supplier_category"))
.collect(java.util.stream.Collectors.toSet());
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
return categories.iterator().next();
@@ -454,17 +464,16 @@ 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) supplierId = jsonLong(snapshot, "partnerId");
if (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"),
supplierId, firstText(jsonText(snapshot, "supplierName"),
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
supplierId, jsonText(snapshot, "supplierName"),
nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"),
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"),
jsonBoolean(snapshot, "settlementIncluded"), text(row, "status"), true);
quantity, unitPrice, amount, text(row, "currency_code"), text(row, "review_status"),
text(row, "status"), true);
}
private SettlementTransaction transaction(Map<String, Object> row) {
@@ -509,8 +518,8 @@ public class FinanceSettlementService {
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
}
private String documentNo(String prefix) {
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
private String transactionNo() {
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
}
@@ -41,21 +41,6 @@ public class FinanceSupplierValidator {
}
}
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)) {
@@ -0,0 +1,103 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
@Service
public class ReceivableStatisticsService {
private static final String CURRENCY = "CNY";
private final JdbcTemplate jdbc;
public ReceivableStatisticsService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
validate(month, customerId, page, pageSize);
List<ReceivableStatisticsRow> all = rows(month, customerId);
Totals totals = totals(all);
int from = (int) Math.min((long) (page - 1) * pageSize, all.size());
int to = Math.min(from + pageSize, all.size());
return new ReceivableStatisticsResponse(month, CURRENCY, totals.receivable(), totals.received(),
totals.outstanding(), all.size(), page, pageSize, List.copyOf(all.subList(from, to)));
}
public List<ReceivableStatisticsRow> exportRows(YearMonth month, Long customerId) {
validate(month, customerId, 1, 1);
List<ReceivableStatisticsRow> rows = rows(month, customerId);
totals(rows);
return rows;
}
private List<ReceivableStatisticsRow> rows(YearMonth month, Long customerId) {
LocalDate from = month.atDay(1);
LocalDate to = month.plusMonths(1).atDay(1);
StringBuilder sql = new StringBuilder("""
SELECT tenant_id, customer_name_snapshot,
SUM(total_amount) total_receivable,
SUM(paid_amount) total_received,
SUM(outstanding_amount) total_outstanding
FROM fin_settlement
WHERE direction='RECEIVABLE'
AND status<>'VOIDED'
AND currency_code='CNY'
AND generated_date>=?
AND generated_date<?
""");
List<Object> args = new ArrayList<>(List.of(from, to));
if (customerId != null) {
sql.append(" AND tenant_id=?");
args.add(customerId);
}
sql.append(" GROUP BY tenant_id,customer_name_snapshot ORDER BY customer_name_snapshot,tenant_id");
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
rs.getBigDecimal("total_outstanding"))), args.toArray());
}
private static Totals totals(List<ReceivableStatisticsRow> rows) {
BigDecimal receivable = BigDecimal.ZERO;
BigDecimal received = BigDecimal.ZERO;
BigDecimal outstanding = BigDecimal.ZERO;
for (ReceivableStatisticsRow row : rows) {
checked(row);
receivable = receivable.add(row.totalReceivable());
received = received.add(row.totalReceived());
outstanding = outstanding.add(row.totalOutstanding());
}
if (receivable.compareTo(received.add(outstanding)) != 0) inconsistent();
return new Totals(receivable, received, outstanding);
}
private static ReceivableStatisticsRow checked(ReceivableStatisticsRow row) {
if (row.customerId() <= 0 || row.customerName() == null || row.customerName().isBlank()
|| row.totalReceivable() == null || row.totalReceived() == null || row.totalOutstanding() == null
|| row.totalReceivable().signum() < 0 || row.totalReceived().signum() < 0
|| row.totalOutstanding().signum() < 0
|| row.totalReceivable().compareTo(row.totalReceived().add(row.totalOutstanding())) != 0) {
inconsistent();
}
return row;
}
private static void validate(YearMonth month, Long customerId, int page, int pageSize) {
if (month == null) throw new FinanceEventException("FIN-STAT-400", "统计月份不能为空");
if (customerId != null && customerId <= 0) throw new FinanceEventException("FIN-STAT-400", "客户ID必须为正整数");
if (page <= 0 || pageSize <= 0 || pageSize > 500) throw new FinanceEventException("FIN-STAT-400", "分页参数无效");
}
private static void inconsistent() {
throw new FinanceEventException("FIN-STAT-001", "应收统计金额勾稽异常,请核对正式结算数据");
}
private record Totals(BigDecimal receivable, BigDecimal received, BigDecimal outstanding) {}
}
@@ -1,6 +1,8 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationResult;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
@@ -12,6 +14,7 @@ import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -33,7 +36,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
public class ReconciliationStatementService {
@@ -42,10 +44,18 @@ public class ReconciliationStatementService {
private static final Set<String> REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceDocumentNumberGenerator documentNumbers;
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
}
@Autowired
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceDocumentNumberGenerator documentNumbers) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.documentNumbers = documentNumbers;
}
@Transactional
@@ -61,6 +71,7 @@ public class ReconciliationStatementService {
if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源");
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
String statementNo = documentNumbers.next("REC");
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
@@ -68,7 +79,7 @@ public class ReconciliationStatementService {
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, request.customerId());
statement.setString(2, "REC-PENDING-" + UUID.randomUUID());
statement.setString(2, statementNo);
statement.setLong(3, subjectId);
statement.setString(4, customer.name());
statement.setString(5, "CNY");
@@ -85,7 +96,6 @@ public class ReconciliationStatementService {
}, holder);
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
long statementId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
for (Source source : sources) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY')",
request.customerId(), statementId, "FIRSTLEG_FEE", source.id(), source.sourceVersion(),
@@ -123,11 +133,18 @@ public class ReconciliationStatementService {
statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
if ("ISSUE".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
before.customerId(), statementId, before.customerId());
} else if ("VOID".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
before.customerId(), statementId, before.customerId());
} else if ("REOPEN".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
}
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
@@ -149,7 +166,7 @@ public class ReconciliationStatementService {
BigDecimal.class, before.customerId(), statementId);
if (total == null) total = BigDecimal.ZERO;
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId,
before.customerId(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
@@ -175,22 +192,60 @@ public class ReconciliationStatementService {
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
(rs, row) -> payment(rs), customerId, statementId);
List<ReconciliationRevision> revisions = jdbc.query(
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND before_data IS NOT NULL ORDER BY created_at,id",
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id",
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
bill.customerConfirmedAt(),
bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(),
bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
@Transactional
public CustomerConfirmationResult confirmForCustomer(long customerId, long operatorId, long statementId,
String key, CustomerConfirmationRequest request) {
if (customerId <= 0 || operatorId <= 0 || statementId <= 0 || request == null || request.expectedVersion() < 0) {
throw error("FIN_400", "客户确认请求无效");
}
Bill before = bill(customerId, statementId);
Long replay = reserve(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key,
"POST", "/api/client/finance/v1/statements/" + statementId + "/confirm", request);
if (replay != null) return customerConfirmation(customerId, replay);
if (!Set.of("ISSUED", "PARTIALLY_PAID", "PAID").contains(before.status())) {
throw error("FIN_409", "仅已出账对账单允许客户确认");
}
if (!"PENDING_CONFIRMATION".equals(before.customerConfirmationStatus())) {
throw error("FIN_409", "对账单已确认或当前无需确认");
}
String operatorName = FinanceOperatorSnapshot.requiredName(operatorId);
int changed = jdbc.update("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED',customer_confirmed_at=CURRENT_TIMESTAMP(3),customer_confirmed_by=?,customer_confirmed_by_name=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND customer_confirmation_status='PENDING_CONFIRMATION' AND version=?",
operatorId, operatorName, operatorId, operatorName, statementId, customerId, request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
CustomerConfirmationResult after = customerConfirmation(customerId, statementId);
audit(customerId, statementId, "RECONCILIATION_CUSTOMER_CONFIRM", null, before, after, operatorId);
complete(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key, statementId);
return after;
}
private CustomerConfirmationResult customerConfirmation(long customerId, long statementId) {
Bill current = bill(customerId, statementId);
if (!"CONFIRMED".equals(current.customerConfirmationStatus()) || current.customerConfirmedBy() == null
|| current.customerConfirmedAt() == null) {
throw error("FIN_409", "对账单尚未完成客户确认");
}
return new CustomerConfirmationResult(current.id(), current.customerConfirmationStatus(),
current.customerConfirmedBy(), current.customerConfirmedByName(), current.customerConfirmedAt(), current.version());
}
public ReconciliationDetail detailForPlatform(long statementId) {
return detail(bill(statementId).customerId(), statementId);
}
private List<Source> eligibleSources(CreateReconciliationRequest request) {
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND review_status='APPROVED' AND amount>0 AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.publicationStatus'))='PUBLISHED' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
List<Map<String, Object>> selected = jdbc.queryForList(
@@ -453,6 +508,8 @@ public class ReconciliationStatementService {
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), timestamp(rs, "customer_confirmed_at"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getString("updated_by_name"),
@@ -480,8 +537,9 @@ public class ReconciliationStatementService {
return rs.wasNull() ? null : value;
}
private static String stableNo(long id) {
return "REC-" + String.format(Locale.ROOT, "%020d", id);
private static LocalDateTime timestamp(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toLocalDateTime();
}
private static FinanceEventException error(String code, String message) {
@@ -496,6 +554,8 @@ public class ReconciliationStatementService {
String feeItemName, BigDecimal amount) {}
private record Bill(long id, long customerId, String number, String customerName, String status,
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
int creditDays, String remark, long createdBy, String createdByName,
LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {}
}