优化
This commit is contained in:
@@ -10618,6 +10618,9 @@ BEGIN
|
||||
SET v_signature_before=SHA2(CONCAT_WS(CHAR(31),v_column_type,v_is_nullable,
|
||||
COALESCE(v_column_default,'<NULL>'),COALESCE(v_extra,''),COALESCE(v_character_set,''),
|
||||
COALESCE(v_collation,''),COALESCE(v_generation_expression,'')),256);
|
||||
-- MySQL 8.0/8.4 的 information_schema 会把生成表达式单引号返回为反斜杠转义形式。
|
||||
-- 结构签名使用原值;仅在重建 DDL 时恢复为可执行的表达式文本。
|
||||
SET v_generation_expression=REPLACE(v_generation_expression,CONCAT(CHAR(92),CHAR(39)),CHAR(39));
|
||||
SET v_extra_sql=TRIM(REPLACE(COALESCE(v_extra,''),'DEFAULT_GENERATED',''));
|
||||
IF COALESCE(v_generation_expression,'')<>'' THEN
|
||||
IF UPPER(v_extra_sql) NOT IN ('STORED GENERATED','VIRTUAL GENERATED')
|
||||
@@ -13183,7 +13186,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'updated_
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'version', '乐观锁版本号');
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'warehouse_id', '仓库ID');
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_pallet_reservation', '托盘活动预占唯一标记(包含A0)');
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_reservation', '普通库位活动预占唯一标记');
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_reservation', '普通库位活动预占唯一标记,A0 返回 NULL');
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'business_id', '来源服务入库单ID');
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'confirmed_at', '确认占用时间');
|
||||
CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'created_at', '创建时间');
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
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.service.FinancePhase1OperationService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
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.LocalDate;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/finance/v1")
|
||||
public class FinancePhase1OperationController {
|
||||
private final FinancePhase1OperationService service;
|
||||
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/accounts")
|
||||
@PreAuthorize("hasAuthority('platform:finance:account:view')")
|
||||
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String status) {
|
||||
return service.listDerivedCustomerAccounts(page, pageSize, keyword, status);
|
||||
}
|
||||
|
||||
@GetMapping("/accounts/{customerId}/ledger")
|
||||
@PreAuthorize("hasAuthority('platform:finance:account:view')")
|
||||
public Object listDerivedCustomerAccountLedger(@PathVariable long customerId,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@RequestParam(required = false) String businessType,
|
||||
@RequestParam(required = false) LocalDate startDate,
|
||||
@RequestParam(required = false) LocalDate endDate) {
|
||||
return service.listDerivedCustomerAccountLedger(customerId, page, pageSize, businessType, startDate, endDate);
|
||||
}
|
||||
|
||||
@GetMapping("/fee-sources/{feeSourceId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:view')")
|
||||
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
|
||||
return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId));
|
||||
}
|
||||
|
||||
@PostMapping("/fee-sources/{feeSourceId}/review")
|
||||
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
|
||||
public ApiResponse<?> reviewFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long feeSourceId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ReviewRequest request) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
|
||||
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope;
|
||||
import com.baiyue.finance.service.FinanceSettlementService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/finance/v1")
|
||||
public class FinanceSettlementController {
|
||||
private final FinanceSettlementService service;
|
||||
|
||||
public FinanceSettlementController(FinanceSettlementService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping("/receivable-settlements")
|
||||
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:create')")
|
||||
public SettlementDetailEnvelope createReceivableSettlement(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreateReceivableSettlementRequest request) {
|
||||
return success(service.createReceivableSettlement(user.userId(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/payable-settlements")
|
||||
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:create')")
|
||||
public SettlementDetailEnvelope createPayableSettlement(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreatePayableSettlementRequest request) {
|
||||
return success(service.createPayableSettlement(user.userId(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@GetMapping("/settlements/{settlementId}")
|
||||
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view')")
|
||||
public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long settlementId) {
|
||||
SettlementDetail detail = service.getSettlement(settlementId);
|
||||
String permission = "RECEIVABLE".equals(detail.summary().direction())
|
||||
? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view";
|
||||
if (user == null || !user.permissions().contains(permission)) {
|
||||
throw new AccessDeniedException("无权查看该方向结算单");
|
||||
}
|
||||
return success(detail);
|
||||
}
|
||||
|
||||
@PostMapping("/settlements/{settlementId}/transactions")
|
||||
@PreAuthorize("hasAuthority('platform:finance:payment:create')")
|
||||
public SettlementDetailEnvelope createSettlementPayment(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long settlementId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreateSettlementPaymentRequest request) {
|
||||
return success(service.createSettlementPayment(settlementId, user.userId(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
private static SettlementDetailEnvelope success(SettlementDetail detail) {
|
||||
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 SettlementDetailEnvelope("200", "success", detail, requestId, traceId, Instant.now());
|
||||
}
|
||||
|
||||
private static String first(String first, String second) {
|
||||
return StringUtils.hasText(first) ? first : second;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.finance.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
||||
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/finance/v1")
|
||||
public class ReconciliationStatementController {
|
||||
private final ReconciliationStatementService service;
|
||||
|
||||
public ReconciliationStatementController(ReconciliationStatementService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping("/statements")
|
||||
@PreAuthorize("hasAuthority('platform:finance:reconciliation:create')")
|
||||
public ApiResponse<ReconciliationDetail> createReconciliationStatement(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreateReconciliationRequest request) {
|
||||
return FinanceResponses.success(service.create(user.userId(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/reconciliation-statements/{statementId}/actions")
|
||||
@PreAuthorize("hasAuthority('platform:finance:reconciliation:review')")
|
||||
public ApiResponse<ReconciliationDetail> actionReconciliationStatement(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long statementId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody DocumentActionRequest request) {
|
||||
return FinanceResponses.success(service.action(user.userId(), statementId, idempotencyKey, request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.finance.dto;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class FinancePhase1Dtos {
|
||||
private FinancePhase1Dtos() {}
|
||||
|
||||
public record PageMeta(int page, int pageSize, long total) {}
|
||||
public record PageEnvelope<T>(String code, String message, List<T> data, PageMeta page,
|
||||
String requestId, String traceId, Instant timestamp) {
|
||||
public static <T> PageEnvelope<T> success(List<T> data, int page, int pageSize, long total) {
|
||||
String requestId = correlation("requestId", "request_id");
|
||||
String traceId = correlation("traceId", "trace_id");
|
||||
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
|
||||
if (!StringUtils.hasText(traceId)) traceId = requestId;
|
||||
return new PageEnvelope<>("200", "success", data, new PageMeta(page, pageSize, total), requestId, traceId, Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
public record AccountSummary(long customerId, String customerCode, String customerName,
|
||||
String contactName, String contactPhone,
|
||||
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
|
||||
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
|
||||
String status, LocalDateTime lastChangedAt) {}
|
||||
|
||||
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
|
||||
String documentType, long documentId, String documentNo,
|
||||
BigDecimal receivableChange, BigDecimal payableChange,
|
||||
BigDecimal outstandingReceivableAfter, String operatorName,
|
||||
String remark) {}
|
||||
|
||||
public record FeeSourceDetail(long feeSourceId, String sourceType, long sourceFeeId,
|
||||
int sourceVersion, String direction, long customerId,
|
||||
String customerName, Long supplierId, String supplierName,
|
||||
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 sourceStatus, boolean statementGenerated,
|
||||
String reviewReason, String settlementReason, 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) {}
|
||||
|
||||
private static String correlation(String first, String second) {
|
||||
String value = MDC.get(first);
|
||||
return StringUtils.hasText(value) ? value : MDC.get(second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.finance.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Stable DTOs for the phase-one AR/AP settlement operations. */
|
||||
public final class FinanceSettlementDtos {
|
||||
private FinanceSettlementDtos() {}
|
||||
|
||||
public record CreateReceivableSettlementRequest(long reconciliationStatementId, String currency) {}
|
||||
public record CreatePayableSettlementRequest(long customerId, long supplierId, List<Long> feeSourceIds,
|
||||
LocalDate dueDate, String currency) {}
|
||||
public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency,
|
||||
String method, String referenceNo, List<String> voucherFileIds,
|
||||
Integer expectedVersion, String remark) {}
|
||||
|
||||
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
|
||||
String customerName, Long supplierId, String supplierName,
|
||||
Long reconciliationStatementId, String businessPeriod, String currency,
|
||||
BigDecimal totalAmount, BigDecimal paidAmount, BigDecimal outstandingAmount,
|
||||
LocalDate generatedDate, LocalDate dueDate, String paymentStatus,
|
||||
String status, int version) {}
|
||||
public record SettlementLine(long feeSourceId, String sourceType, long sourceFeeId, int sourceVersion,
|
||||
String direction, long customerId, String customerName, Long supplierId,
|
||||
String supplierName, Long inboundId, String inboundCode, Long containerId,
|
||||
String containerNo, LocalDate businessDate, String feeItemName,
|
||||
BigDecimal amount, String currency, String sourceStatus) {}
|
||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||
BigDecimal amount, String method, String referenceNo,
|
||||
List<String> voucherFileIds, long createdBy, LocalDateTime createdAt,
|
||||
String remark) {}
|
||||
public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount,
|
||||
BigDecimal afterAmount, String reason, long operatorId,
|
||||
LocalDateTime occurredAt) {}
|
||||
public record SettlementDetail(SettlementSummary summary, List<SettlementLine> lines,
|
||||
List<SettlementTransaction> transactions,
|
||||
List<SettlementRevision> revisions) {}
|
||||
public record SettlementDetailEnvelope(String code, String message, SettlementDetail data,
|
||||
String requestId, String traceId, Instant timestamp) {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.finance.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class ReconciliationStatementDtos {
|
||||
private ReconciliationStatementDtos() {}
|
||||
|
||||
public record CreateReconciliationRequest(long customerId, String generationMode,
|
||||
List<Long> feeSourceIds, LocalDate periodStart,
|
||||
LocalDate periodEnd, String remark) {}
|
||||
|
||||
public record DocumentActionRequest(String action, String reason, int expectedVersion) {}
|
||||
|
||||
public record ReconciliationDetail(long statementId, String statementNo, long customerId,
|
||||
String customerName, LocalDate periodStart, LocalDate periodEnd,
|
||||
int creditDays, String currency, BigDecimal totalReceivable,
|
||||
BigDecimal receivedAmount, BigDecimal outstandingAmount,
|
||||
boolean settled, String status, int version,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt, String remark,
|
||||
List<FeeSourceLine> lines, List<PaymentHistory> paymentHistory) {}
|
||||
|
||||
public record FeeSourceLine(long feeSourceId, String sourceType, long sourceFeeId,
|
||||
int sourceVersion, String direction, long customerId,
|
||||
Long orderId, Long inboundId, Long containerId,
|
||||
String feeItemCode, String feeItemName, BigDecimal amount,
|
||||
String currency, String sourceStatus) {}
|
||||
|
||||
public record PaymentHistory(long transactionId, String transactionType,
|
||||
LocalDate transactionDate, BigDecimal amount,
|
||||
String method, String referenceNo, long createdBy,
|
||||
LocalDateTime createdAt, String remark) {}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
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.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class FinancePhase1OperationService {
|
||||
private static final String CUSTOMER_IDS = "SELECT tenant_id customer_id FROM fin_customer_settlement_profile UNION SELECT tenant_id FROM fin_bill UNION SELECT customer_id FROM fin_fee_source UNION SELECT tenant_id FROM fin_cost";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) {
|
||||
page = page(page); pageSize = pageSize(pageSize);
|
||||
String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) throw error("FIN-REQ-400", "账户状态无效");
|
||||
String filter = " WHERE (? IS NULL OR CAST(c.customer_id AS CHAR) LIKE CONCAT('%',?,'%') OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)";
|
||||
Object[] filterArgs = {blankToNull(keyword), blankToNull(keyword), blankToNull(keyword), normalizedStatus, normalizedStatus};
|
||||
String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter;
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs);
|
||||
String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," +
|
||||
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill b WHERE b.tenant_id=c.customer_id AND b.status<>'VOIDED') outstanding_receivable," +
|
||||
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.source_fee_id AND bl.source_version=f.source_fee_version)) pending_billing," +
|
||||
"(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," +
|
||||
"(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status<>'VOIDED') outstanding_payable," +
|
||||
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
|
||||
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
|
||||
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
|
||||
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
|
||||
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "outstanding_payable"), rs.getString("account_status"), time(rs, "last_changed_at")), args);
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
}
|
||||
|
||||
public PageEnvelope<LedgerEntry> listDerivedCustomerAccountLedger(long customerId, int page, int pageSize,
|
||||
String businessType, LocalDate startDate, LocalDate endDate) {
|
||||
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
|
||||
if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
|
||||
List<RawLedger> rows = new ArrayList<>();
|
||||
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
|
||||
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
|
||||
BigDecimal outstanding = BigDecimal.ZERO;
|
||||
List<LedgerEntry> projected = new ArrayList<>();
|
||||
for (RawLedger row : rows) {
|
||||
BigDecimal receivableChange = row.receivable();
|
||||
outstanding = outstanding.add(receivableChange);
|
||||
BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO;
|
||||
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, "operator-" + row.operatorId(), row.remark());
|
||||
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
|
||||
}
|
||||
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
|
||||
.thenComparing(Comparator.comparingLong(LedgerEntry::ledgerId).reversed()));
|
||||
int from = Math.min((page - 1) * pageSize, projected.size());
|
||||
int to = Math.min(from + pageSize, projected.size());
|
||||
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
||||
}
|
||||
|
||||
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
||||
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
|
||||
List<FeeSourceDetail> rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId);
|
||||
if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在");
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FeeSourceDetail reviewFinanceFeeSource(long feeSourceId, long operatorId, String key, ReviewRequest request) {
|
||||
if (request == null || !List.of("APPROVE", "REJECT").contains(request.action()) || request.expectedVersion() < 0) throw error("FIN-REQ-400", "审核请求无效");
|
||||
if ("REJECT".equals(request.action()) && !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "驳回原因不能为空");
|
||||
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
|
||||
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'",
|
||||
review, request.reason(), feeSourceId, request.expectedVersion());
|
||||
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
|
||||
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
|
||||
audit(after.customerId(), feeSourceId, "FEE_SOURCE_REVIEW_" + request.action(), request.reason(), before, after, operatorId);
|
||||
complete(after.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, feeSourceId);
|
||||
return after;
|
||||
}
|
||||
|
||||
@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", "参与结算请求无效");
|
||||
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);
|
||||
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", "费用来源状态或版本不允许修改参与结算状态");
|
||||
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);
|
||||
return after;
|
||||
}
|
||||
|
||||
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
long id = rs.getLong("id");
|
||||
boolean generated = Boolean.TRUE.equals(jdbc.queryForObject("SELECT COUNT(*)>0 FROM fin_bill_line WHERE source_id=? AND source_version=?", Boolean.class, rs.getLong("source_fee_id"), rs.getInt("source_fee_version")));
|
||||
String status = rs.getString("status");
|
||||
if (generated && "AVAILABLE".equals(status)) status = "BILLED";
|
||||
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_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), null, null,
|
||||
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));
|
||||
}
|
||||
|
||||
private List<OperationLog> logs(long customerId, long id) {
|
||||
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
|
||||
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id);
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key");
|
||||
String hash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
|
||||
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", customerId, operatorId, operation, key);
|
||||
if (!rows.isEmpty()) {
|
||||
Map<String,Object> row = rows.get(0);
|
||||
if (!hash.equals(row.get("request_hash"))) throw error("FIN-IDEM-409", "Idempotency-Key已用于其他请求");
|
||||
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
|
||||
throw error("FIN-IDEM-409", "相同请求正在处理中");
|
||||
}
|
||||
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", customerId, operatorId, operation, key, hash);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void complete(long customerId, long operatorId, String operation, String key, long id) {
|
||||
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", id, customerId, operatorId, operation, key);
|
||||
}
|
||||
|
||||
private void audit(long customerId, long id, String action, String reason, Object before, Object after, long operatorId) {
|
||||
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
|
||||
}
|
||||
|
||||
private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException {
|
||||
LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at");
|
||||
BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
|
||||
BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO;
|
||||
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable, rs.getLong("created_by"), rs.getString("remark"));
|
||||
}
|
||||
private record RawLedger(long id, LocalDateTime occurredAt, String businessType, String documentType,
|
||||
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId, String remark) {}
|
||||
|
||||
private static int page(int value) { if (value < 1) throw error("FIN-REQ-400", "页码必须大于0"); return value; }
|
||||
private static int pageSize(int value) { if (value < 1 || value > 200) throw error("FIN-REQ-400", "每页数量必须在1到200之间"); return value; }
|
||||
private static void customer(long value) { if (value <= 0) throw error("FIN-REQ-400", "客户ID无效"); }
|
||||
private static boolean inRange(LocalDateTime value, LocalDate start, LocalDate end) { return value != null && (start == null || !value.toLocalDate().isBefore(start)) && (end == null || !value.toLocalDate().isAfter(end)); }
|
||||
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static BigDecimal money(ResultSet rs, String column) throws SQLException { BigDecimal value = rs.getBigDecimal(column); return value == null ? BigDecimal.ZERO : value; }
|
||||
private static LocalDateTime time(ResultSet rs, String column) throws SQLException { Timestamp value = rs.getTimestamp(column); return value == null ? null : value.toLocalDateTime(); }
|
||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
||||
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
|
||||
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
||||
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
|
||||
private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; }
|
||||
private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); }
|
||||
private static LocalDate date(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? LocalDate.of(1970, 1, 1) : LocalDate.parse(value); }
|
||||
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.FeeSourceLine;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class ReconciliationStatementService {
|
||||
private static final Set<String> MODES = Set.of("BY_CUTOFF", "SELECTED");
|
||||
private static final Set<String> ACTIONS = Set.of("SUBMIT", "APPROVE", "REJECT", "VOID");
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
||||
validateCreate(request);
|
||||
Long replay = reserve(request.customerId(), operatorId, "RECONCILIATION_CREATE", key,
|
||||
"POST", "/api/platform/finance/v1/statements", request);
|
||||
if (replay != null) return detail(request.customerId(), replay);
|
||||
|
||||
CustomerProfile customer = customer(request.customerId());
|
||||
long subjectId = activeSubject();
|
||||
List<Source> sources = eligibleSources(request);
|
||||
if (sources.isEmpty()) throw error("FIN-REC-409", "没有符合结算条件的CNY应收费用来源");
|
||||
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
|
||||
|
||||
KeyHolder holder = new GeneratedKeyHolder();
|
||||
jdbc.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"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,updated_by) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?)",
|
||||
Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setLong(1, request.customerId());
|
||||
statement.setString(2, "REC-PENDING-" + UUID.randomUUID());
|
||||
statement.setLong(3, subjectId);
|
||||
statement.setString(4, customer.name());
|
||||
statement.setString(5, "CNY");
|
||||
statement.setObject(6, request.periodEnd());
|
||||
statement.setObject(7, dueDate);
|
||||
statement.setBigDecimal(8, total);
|
||||
statement.setBigDecimal(9, total);
|
||||
statement.setString(10, request.remark());
|
||||
statement.setLong(11, operatorId);
|
||||
statement.setLong(12, operatorId);
|
||||
return statement;
|
||||
}, holder);
|
||||
if (holder.getKey() == null) throw error("FIN-DATA-500", "对账单主键生成失败");
|
||||
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.sourceFeeId(), source.sourceVersion(),
|
||||
source.sourceNo(), source.orderId(), source.inboundId(), source.feeItemCode(), source.feeItemName(), source.amount());
|
||||
int locked = jdbc.update("UPDATE fin_fee_source SET status='LOCKED' WHERE id=? AND customer_id=? AND status='AVAILABLE'",
|
||||
source.id(), request.customerId());
|
||||
if (locked != 1) throw error("FIN-REC-409", "费用来源已被其他对账单占用");
|
||||
}
|
||||
CreationMetadata metadata = new CreationMetadata(request.generationMode().toUpperCase(Locale.ROOT),
|
||||
request.periodStart(), request.periodEnd());
|
||||
audit(request.customerId(), statementId, "RECONCILIATION_CREATED", request.remark(), null, metadata, operatorId);
|
||||
complete(request.customerId(), operatorId, "RECONCILIATION_CREATE", key, statementId);
|
||||
return detail(request.customerId(), statementId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReconciliationDetail action(long operatorId, long statementId, String key, DocumentActionRequest request) {
|
||||
validateAction(statementId, request);
|
||||
Bill before = bill(statementId);
|
||||
Long replay = reserve(before.customerId(), operatorId, "RECONCILIATION_ACTION", key,
|
||||
"POST", "/api/platform/finance/v1/reconciliation-statements/" + statementId + "/actions", request);
|
||||
if (replay != null) return detail(before.customerId(), replay);
|
||||
|
||||
String action = request.action().toUpperCase(Locale.ROOT);
|
||||
Transition transition = transition(action, before.status());
|
||||
if ("VOID".equals(action)) requireNoReceipt(before.customerId(), statementId);
|
||||
snapshot(before, request.reason(), operatorId);
|
||||
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='APPROVED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='APPROVED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
|
||||
transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId,
|
||||
transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
|
||||
if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化");
|
||||
if ("APPROVE".equals(action)) {
|
||||
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.source_fee_id AND l.source_version=f.source_fee_version 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());
|
||||
}
|
||||
ReconciliationDetail after = detail(before.customerId(), statementId);
|
||||
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
|
||||
complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId);
|
||||
return after;
|
||||
}
|
||||
|
||||
public ReconciliationDetail detail(long customerId, long statementId) {
|
||||
Bill bill = bill(customerId, statementId);
|
||||
CreationMetadata metadata = creationMetadata(customerId, statementId);
|
||||
List<FeeSourceLine> lines = jdbc.query(
|
||||
"SELECT f.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_code,f.fee_item_name,f.amount,f.currency_code,f.status FROM fin_bill_line l JOIN fin_fee_source f ON f.source_fee_id=l.source_id AND f.source_fee_version=l.source_version AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
|
||||
(rs, row) -> line(rs), customerId, statementId);
|
||||
List<PaymentHistory> payments = jdbc.query(
|
||||
"SELECT r.id,r.received_at,r.received_amount,r.payment_method,r.voucher_no,r.created_by,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);
|
||||
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, bill.status(), bill.version(), bill.createdAt(),
|
||||
bill.updatedAt(), bill.remark(), lines, payments);
|
||||
}
|
||||
|
||||
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 ?");
|
||||
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
|
||||
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
|
||||
sql.append(" AND id IN (");
|
||||
for (int index = 0; index < request.feeSourceIds().size(); index++) {
|
||||
if (index > 0) sql.append(',');
|
||||
sql.append('?'); args.add(request.feeSourceIds().get(index));
|
||||
}
|
||||
sql.append(')');
|
||||
}
|
||||
sql.append(" ORDER BY id FOR UPDATE");
|
||||
List<Source> rows = jdbc.query(sql.toString(), (rs, row) -> source(rs), args.toArray());
|
||||
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
|
||||
Set<Long> actual = new HashSet<>(rows.stream().map(Source::id).toList());
|
||||
if (!actual.equals(new HashSet<>(request.feeSourceIds()))) {
|
||||
throw error("FIN-REC-409", "所选费用来源不存在、跨客户或不符合结算条件");
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private CustomerProfile customer(long customerId) {
|
||||
List<CustomerProfile> rows = jdbc.query("SELECT customer_name_snapshot,credit_days FROM fin_customer_settlement_profile WHERE tenant_id=? AND status='ACTIVE'",
|
||||
(rs, row) -> new CustomerProfile(rs.getString("customer_name_snapshot"), rs.getInt("credit_days")), customerId);
|
||||
return rows.isEmpty() ? new CustomerProfile("客户" + customerId, 30) : rows.get(0);
|
||||
}
|
||||
|
||||
private long activeSubject() {
|
||||
List<Long> rows = jdbc.query("SELECT id FROM fin_subject WHERE status='ACTIVE' ORDER BY id LIMIT 1", (rs, row) -> rs.getLong(1));
|
||||
if (rows.isEmpty()) throw error("FIN-REC-409", "平台财务主体未配置");
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Bill bill(long statementId) {
|
||||
List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=?",
|
||||
(rs, row) -> bill(rs), statementId);
|
||||
if (rows.isEmpty()) throw error("FIN-REC-404", "对账单不存在");
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Bill bill(long customerId, long statementId) {
|
||||
List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=? AND b.tenant_id=?",
|
||||
(rs, row) -> bill(rs), statementId, customerId);
|
||||
if (rows.isEmpty()) throw error("FIN-REC-404", "对账单不存在或不属于客户");
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private CreationMetadata creationMetadata(long customerId, long statementId) {
|
||||
List<String> rows = jdbc.query("SELECT after_data FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action='RECONCILIATION_CREATED' ORDER BY id LIMIT 1",
|
||||
(rs, row) -> rs.getString(1), customerId, statementId);
|
||||
if (rows.isEmpty()) throw error("FIN-DATA-500", "对账单期间审计快照缺失");
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(rows.get(0));
|
||||
return new CreationMetadata(node.path("generationMode").asText(), LocalDate.parse(node.path("periodStart").asText()), LocalDate.parse(node.path("periodEnd").asText()));
|
||||
} catch (Exception exception) {
|
||||
throw error("FIN-DATA-500", "对账单期间审计快照损坏");
|
||||
}
|
||||
}
|
||||
|
||||
private void snapshot(Bill bill, String reason, long operatorId) {
|
||||
List<Map<String, Object>> lines = jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", bill.customerId(), bill.id());
|
||||
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",
|
||||
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId);
|
||||
}
|
||||
|
||||
private void requireNoReceipt(long customerId, long statementId) {
|
||||
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_receipt_bill WHERE tenant_id=? AND bill_id=?", Long.class, customerId, statementId);
|
||||
if (count != null && count > 0) throw error("FIN-REC-409", "已有收款记录的对账单不可作废");
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key,
|
||||
String method, String path, Object body) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key");
|
||||
String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
|
||||
List<Map<String, Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
|
||||
customerId, operatorId, operation, key);
|
||||
if (!rows.isEmpty()) {
|
||||
Map<String, Object> row = rows.get(0);
|
||||
if (!requestHash.equals(row.get("request_hash"))) throw error("FIN-IDEM-409", "Idempotency-Key已用于其他请求");
|
||||
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
|
||||
if ("FAILED".equals(row.get("status")) && jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
|
||||
customerId, operatorId, operation, key) == 1) return null;
|
||||
LocalDateTime updatedAt = localTime(row.get("updated_at"));
|
||||
if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))
|
||||
&& jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
|
||||
customerId, operatorId, operation, key, updatedAt) == 1) return null;
|
||||
throw error("FIN-IDEM-409", "相同请求正在处理中");
|
||||
}
|
||||
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')",
|
||||
customerId, operatorId, operation, key, requestHash);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
|
||||
int changed = jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'",
|
||||
resourceId, customerId, operatorId, operation, key);
|
||||
if (changed != 1) throw error("FIN-IDEM-409", "幂等请求完成状态冲突");
|
||||
}
|
||||
|
||||
private void audit(long customerId, long statementId, String action, String reason,
|
||||
Object before, Object after, long operatorId) {
|
||||
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
customerId, "RECONCILIATION_STATEMENT", statementId, action, reason, before == null ? null : json(before),
|
||||
after == null ? null : json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
|
||||
}
|
||||
|
||||
private static void validateCreate(CreateReconciliationRequest request) {
|
||||
if (request == null || request.customerId() <= 0 || request.periodStart() == null || request.periodEnd() == null
|
||||
|| request.periodStart().isAfter(request.periodEnd())) throw error("FIN-REQ-400", "对账单客户和期间无效");
|
||||
String mode = request.generationMode() == null ? "" : request.generationMode().toUpperCase(Locale.ROOT);
|
||||
if (!MODES.contains(mode)) throw error("FIN-REQ-400", "对账单生成方式无效");
|
||||
if (request.remark() != null && request.remark().length() > 500) throw error("FIN-REQ-400", "备注不能超过500个字符");
|
||||
if ("SELECTED".equals(mode)) {
|
||||
if (request.feeSourceIds() == null || request.feeSourceIds().isEmpty()) throw error("FIN-REQ-400", "勾选生成必须选择费用来源");
|
||||
if (request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0)
|
||||
|| new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) {
|
||||
throw error("FIN-REQ-400", "费用来源ID无效或重复");
|
||||
}
|
||||
} else if (request.feeSourceIds() != null && !request.feeSourceIds().isEmpty()) {
|
||||
throw error("FIN-REQ-400", "按截止期生成不能同时指定费用来源");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAction(long statementId, DocumentActionRequest request) {
|
||||
if (statementId <= 0 || request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())
|
||||
|| request.reason().length() > 500) throw error("FIN-REQ-400", "对账单动作请求无效");
|
||||
if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN-REQ-400", "对账单动作无效");
|
||||
}
|
||||
|
||||
private static Transition transition(String action, String status) {
|
||||
return switch (action) {
|
||||
case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW");
|
||||
case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "APPROVED");
|
||||
case "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED");
|
||||
case "VOID" -> {
|
||||
if (!Set.of("DRAFT", "PENDING_REVIEW", "REJECTED", "APPROVED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废");
|
||||
yield new Transition(status, "VOIDED");
|
||||
}
|
||||
default -> throw error("FIN-REQ-400", "对账单动作无效");
|
||||
};
|
||||
}
|
||||
|
||||
private static Transition requireTransition(String actual, String expected, String target) {
|
||||
if (!expected.equals(actual)) throw error("FIN-REC-409", "当前状态不允许执行该动作");
|
||||
return new Transition(expected, target);
|
||||
}
|
||||
|
||||
private static Source source(ResultSet rs) throws SQLException {
|
||||
return new Source(rs.getLong("id"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
||||
rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"),
|
||||
nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("fee_item_code"),
|
||||
rs.getString("fee_item_name"), rs.getBigDecimal("amount"));
|
||||
}
|
||||
|
||||
private static FeeSourceLine line(ResultSet rs) throws SQLException {
|
||||
return new FeeSourceLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_fee_id"),
|
||||
rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"),
|
||||
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
|
||||
rs.getString("fee_item_code"), rs.getString("fee_item_name"), rs.getBigDecimal("amount"),
|
||||
rs.getString("currency_code"), rs.getString("status"));
|
||||
}
|
||||
|
||||
private static PaymentHistory payment(ResultSet rs) throws SQLException {
|
||||
Timestamp received = rs.getTimestamp("received_at");
|
||||
return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(),
|
||||
rs.getBigDecimal("received_amount"), rs.getString("payment_method"), rs.getString("voucher_no"),
|
||||
rs.getLong("created_by"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
|
||||
}
|
||||
|
||||
private static Bill bill(ResultSet rs) throws SQLException {
|
||||
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.getInt("credit_days"), rs.getString("remark"), rs.getTimestamp("created_at").toLocalDateTime(),
|
||||
rs.getTimestamp("updated_at").toLocalDateTime());
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (Exception exception) { throw error("FIN-DATA-500", "财务数据序列化失败"); }
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
|
||||
}
|
||||
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
if (value instanceof LocalDateTime dateTime) return dateTime;
|
||||
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
|
||||
long value = rs.getLong(column);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
|
||||
private static String stableNo(long id) {
|
||||
return "REC-" + String.format(Locale.ROOT, "%020d", id);
|
||||
}
|
||||
|
||||
private static FinanceEventException error(String code, String message) {
|
||||
return new FinanceEventException(code, message);
|
||||
}
|
||||
|
||||
private record CustomerProfile(String name, int creditDays) {}
|
||||
private record CreationMetadata(String generationMode, LocalDate periodStart, LocalDate periodEnd) {}
|
||||
private record Transition(String from, String to) {}
|
||||
private record Source(long id, long sourceFeeId, int sourceVersion, String sourceType, String sourceNo,
|
||||
Long orderId, Long inboundId, Long containerId, String feeItemCode,
|
||||
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,
|
||||
int creditDays, String remark, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.controller.FinancePhase1OperationController;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.startsWith;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FinancePhase1OperationBehaviorTest {
|
||||
@Mock FinancePhase1OperationService operationService;
|
||||
|
||||
@Test
|
||||
void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() {
|
||||
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService);
|
||||
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
|
||||
var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0);
|
||||
var inclusion = new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入本期结算", 1);
|
||||
|
||||
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE");
|
||||
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
|
||||
controller.getFinanceFeeSource(88L);
|
||||
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
|
||||
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion);
|
||||
|
||||
verify(operationService).listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE");
|
||||
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
|
||||
verify(operationService).getFinanceFeeSource(88L);
|
||||
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
|
||||
verify(operationService).updateFeeSourceSettlementInclusion(88L, 71L, "include-key", inclusion);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageEnvelopeAlwaysContainsTraceMetadata() {
|
||||
PageEnvelope<String> response = PageEnvelope.success(List.of("row"), 1, 20, 1);
|
||||
assertThat(response.code()).isEqualTo("200");
|
||||
assertThat(response.requestId()).isNotBlank();
|
||||
assertThat(response.traceId()).isNotBlank();
|
||||
assertThat(response.page().total()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), new ObjectMapper());
|
||||
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, new ObjectMapper());
|
||||
|
||||
assertThatThrownBy(() -> service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
|
||||
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 2)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-FEE-409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
|
||||
String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED";
|
||||
int version = "PENDING".equals(review) ? 0 : 1;
|
||||
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(700L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(), any(), any(), any())).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), (Object[]) any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_fee_source SET review_status="), (Object[]) any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_audit_log"), (Object[]) any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any())).thenReturn(1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, new ObjectMapper());
|
||||
|
||||
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||
|
||||
assertThat(result.reviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(result.operationVersion()).isEqualTo(1);
|
||||
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
|
||||
}
|
||||
|
||||
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
|
||||
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(700L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(anyString(), any(), any(), any(), any())).thenReturn(List.of());
|
||||
return jdbc;
|
||||
}
|
||||
|
||||
private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getLong("id")).thenReturn(88L);
|
||||
when(rs.getLong("source_fee_id")).thenReturn(700L);
|
||||
when(rs.getLong("customer_id")).thenReturn(9L);
|
||||
when(rs.getLong("inbound_id")).thenReturn(100L);
|
||||
when(rs.getLong("container_id")).thenReturn(200L);
|
||||
when(rs.getInt("source_fee_version")).thenReturn(3);
|
||||
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
|
||||
when(rs.getString("direction")).thenReturn("RECEIVABLE");
|
||||
when(rs.getString("fee_item_name")).thenReturn("头程运费");
|
||||
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||
when(rs.getString("status")).thenReturn("AVAILABLE");
|
||||
when(rs.getString("source_hash")).thenReturn("a".repeat(64));
|
||||
when(rs.getString("review_status")).thenReturn(reviewStatus);
|
||||
when(rs.getString("review_reason")).thenReturn(null);
|
||||
when(rs.getString("settlement_reason")).thenReturn(null);
|
||||
when(rs.getBoolean("settlement_included")).thenReturn(included);
|
||||
when(rs.getInt("operation_version")).thenReturn(version);
|
||||
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
|
||||
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"settlementIncluded\":" + included + ",\"operationVersion\":" + version + "}");
|
||||
return rs;
|
||||
}
|
||||
}
|
||||
+1
@@ -116,6 +116,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("CREATE PROCEDURE apply_queued_comments")
|
||||
.contains("NOT (BINARY v_current_comment=BINARY p_column_comment)")
|
||||
.contains("SET v_signature_before=SHA2")
|
||||
.contains("REPLACE(v_generation_expression,CONCAT(CHAR(92),CHAR(39)),CHAR(39))")
|
||||
.contains("BINARY target.signature_before")
|
||||
.contains("字段注释升级遇到未知数据类型")
|
||||
.contains("字段注释升级遇到未知EXTRA定义")
|
||||
|
||||
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
|
||||
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
||||
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
||||
$ExpectedSha256 = '03234DF522426213FA64768FDF35182DC315F2E358E5FD993AA24A2E99936D63'
|
||||
$ExpectedLines = 13775
|
||||
$ExpectedSha256 = 'CD21001B8E0B5D751000754522D5158BD45AD5986ECA8D2E64B8389FBE82B260'
|
||||
$ExpectedLines = 13800
|
||||
|
||||
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
||||
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
|
||||
|
||||
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记',
|
||||
active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL',
|
||||
active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no),
|
||||
|
||||
Reference in New Issue
Block a user