diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cb880049a..f72a9d913 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10618,6 +10618,9 @@ BEGIN SET v_signature_before=SHA2(CONCAT_WS(CHAR(31),v_column_type,v_is_nullable, COALESCE(v_column_default,''),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', '创建时间'); diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java new file mode 100644 index 000000000..8a16aea60 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -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)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java new file mode 100644 index 000000000..5595c86b0 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/FinanceSettlementController.java @@ -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; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java new file mode 100644 index 000000000..b4400259c --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ReconciliationStatementController.java @@ -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 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 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)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java new file mode 100644 index 000000000..0ac962f23 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -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(String code, String message, List data, PageMeta page, + String requestId, String traceId, Instant timestamp) { + public static PageEnvelope success(List 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 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); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java new file mode 100644 index 000000000..ef2bdbd0e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -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 feeSourceIds, + LocalDate dueDate, String currency) {} + public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency, + String method, String referenceNo, List 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 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 lines, + List transactions, + List revisions) {} + public record SettlementDetailEnvelope(String code, String message, SettlementDetail data, + String requestId, String traceId, Instant timestamp) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java new file mode 100644 index 000000000..869d5f391 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java @@ -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 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 lines, List 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) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java new file mode 100644 index 000000000..fea8f2d2c --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -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 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 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 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 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 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 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 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> 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 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); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java new file mode 100644 index 000000000..c9e95bb4f --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -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 MODES = Set.of("BY_CUTOFF", "SELECTED"); + private static final Set 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 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 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 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 eligibleSources(CreateReconciliationRequest request) { + List 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 rows = jdbc.query(sql.toString(), (rs, row) -> source(rs), args.toArray()); + if ("SELECTED".equalsIgnoreCase(request.generationMode())) { + Set 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 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 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 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 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 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> 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> 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 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) {} +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java new file mode 100644 index 000000000..fd53990b2 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -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 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 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 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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 95c24f03b..94875252b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -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定义") diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index a9faa068b..9175e63fc 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -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.') diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql index 540b3a541..fe7ec30de 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql @@ -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),