This commit is contained in:
2026-08-19 09:14:30 +08:00
parent 8845a66749
commit ff1d330c2d
91 changed files with 1538 additions and 523 deletions
+9
View File
@@ -94,4 +94,13 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
+9
View File
@@ -90,5 +90,14 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
@@ -4850,6 +4850,13 @@ DELIMITER ;
CALL phase3_add_allocation_picked_count();
DROP PROCEDURE phase3_add_allocation_picked_count;
CREATE TABLE IF NOT EXISTS fl_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818',
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
@@ -10954,6 +10961,13 @@ CREATE TABLE IF NOT EXISTS fin_idempotency_record (
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
CREATE TABLE IF NOT EXISTS fin_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如REC:20260818',
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
CREATE TABLE IF NOT EXISTS fin_audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
+9
View File
@@ -173,6 +173,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
management.endpoints.web.exposure.include: health,info,prometheus
logging:
@@ -19,6 +19,13 @@ class ExportDefinitionRegistryTest {
registry.setDefinitions(Map.of("FINANCE_RECONCILIATION_STATEMENT_LIST", finance));
assertDoesNotThrow(registry::validate);
}
@Test void acceptsTheReceivableStatisticsProvider() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches");
finance.setRequiredPermission("platform:finance:receivable-statistics:export");
registry.setDefinitions(Map.of("FINANCE_RECEIVABLE_STATISTICS", finance));
assertDoesNotThrow(registry::validate);
}
@Test void rejectsSsrfAndPathTypeMismatch() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
@@ -5,6 +5,7 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -15,20 +16,23 @@ import java.time.LocalDateTime;
public interface FirstlegFeeSourceClient {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long inboundId, @PathVariable long feeId);
@PathVariable long inboundId, @PathVariable long feeId,
@RequestParam int sourceVersion);
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long containerId, @PathVariable long feeId);
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam int sourceVersion);
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
String customerName, Long partnerId, String partnerName,
String customerName, Long supplierId, String supplierName,
String supplierCategory,
Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
String publicationStatus, boolean settlementIncluded,
String publicationStatus,
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
String chargeUnit, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
@@ -4,8 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -16,9 +19,12 @@ import org.springframework.web.bind.annotation.*;
public class ClientFinanceController {
private final FinanceService service;
private final FinanceQueryService queryService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
private final ReconciliationStatementService reconciliationStatementService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService,
ReconciliationStatementService reconciliationStatementService) {
this.service = service;
this.queryService = queryService;
this.reconciliationStatementService = reconciliationStatementService;
}
@GetMapping("/bills")
@@ -45,6 +51,17 @@ public class ClientFinanceController {
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
}
@PostMapping("/statements/{statementId}/confirm")
@PreAuthorize("hasAuthority('merchant:finance:statement:confirm')")
@Operation(summary = "客户确认本人已出账对账单")
public ApiResponse<?> confirmStatement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long statementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CustomerConfirmationRequest request) {
return FinanceResponses.success(reconciliationStatementService.confirmForCustomer(
user.tenantId(), user.userId(), statementId, idempotencyKey, request));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询结算应收")
@@ -3,7 +3,8 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -64,13 +65,12 @@ public class FinancePhase1OperationController {
@RequestParam(required = false) String direction,
@RequestParam(required = false) String status,
@RequestParam(required = false) String reviewStatus,
@RequestParam(required = false) Boolean settlementIncluded,
@RequestParam(required = false) Boolean statementGenerated,
@RequestParam(required = false) LocalDate businessDateFrom,
@RequestParam(required = false) LocalDate businessDateTo,
@RequestParam(required = false) String keyword) {
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
settlementIncluded, statementGenerated, businessDateFrom, businessDateTo, keyword);
statementGenerated, businessDateFrom, businessDateTo, keyword);
}
@GetMapping("/fee-sources/{feeSourceId}")
@@ -88,12 +88,39 @@ public class FinancePhase1OperationController {
return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PutMapping("/fee-sources/{feeSourceId}/settlement-inclusion")
@PreAuthorize("hasAuthority('platform:finance:fee-source:settlement')")
public ApiResponse<?> updateFeeSourceSettlementInclusion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SettlementInclusionRequest request) {
return FinanceResponses.success(service.updateFeeSourceSettlementInclusion(feeSourceId, user.userId(), key, request));
@PutMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> editFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, false));
}
@PostMapping("/fee-sources/{feeSourceId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> submitFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.submitFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PostMapping("/fee-sources/{feeSourceId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> adjustFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, true));
}
@PostMapping("/fee-sources/{feeSourceId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> voidFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.voidFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
}
@@ -105,22 +105,6 @@ public class PlatformFinanceController {
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
}
@PostMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
}
@PostMapping("/bills/{billId}/issue")
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@@ -228,7 +212,6 @@ public class PlatformFinanceController {
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
public record TermsRequest(String customerName, Integer creditDays) {}
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {}
public record VersionRequest(int expectedVersion) {}
public record ReasonRequest(int expectedVersion, String reason) {}
@@ -0,0 +1,42 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.service.ReceivableStatisticsService;
import com.baiyue.finance.service.FinanceEventException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.YearMonth;
@Tag(name = "应收款统计")
@RestController
@RequestMapping("/api/platform/finance/v1/receivable-statistics")
public class ReceivableStatisticsController {
private final ReceivableStatisticsService service;
public ReceivableStatisticsController(ReceivableStatisticsService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:finance:receivable-statistics:list')")
@Operation(summary = "按生成月份和客户汇总CNY正式应收结算单")
public ApiResponse<ReceivableStatisticsResponse> query(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM") String month,
@RequestParam(required = false) Long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
try {
return FinanceResponses.success(service.query(YearMonth.parse(month), customerId, page, pageSize));
} catch (java.time.format.DateTimeParseException exception) {
throw new FinanceEventException("FIN-STAT-400", "统计月份格式必须为yyyy-MM");
}
}
}
@@ -44,7 +44,7 @@ public final class FinancePhase1Dtos {
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
@@ -54,15 +54,16 @@ public final class FinancePhase1Dtos {
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated,
String reviewReason, String settlementReason, String sourceHash,
String reviewReason, String sourceHash,
int operationVersion, List<OperationLog> operationLogs) {}
public record OperationLog(LocalDateTime occurredAt, String action, String operatorName,
String result, String remark) {}
public record ReviewRequest(String action, String reason, int expectedVersion) {}
public record SettlementInclusionRequest(boolean included, String reason, int expectedVersion) {}
public record FeeSourceEditRequest(BigDecimal amount, String reason, int expectedVersion) {}
public record FeeSourceActionRequest(String reason, int expectedVersion) {}
private static String correlation(String first, String second) {
String value = MDC.get(first);
@@ -69,6 +69,10 @@ public final class FinanceReadModels {
String currency,
String status,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
long createdBy,
String createdByName,
LocalDateTime createdAt,
@@ -99,6 +103,10 @@ public final class FinanceReadModels {
LocalDateTime reviewedAt,
LocalDateTime issuedAt,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
String remark,
long createdBy,
String createdByName,
@@ -35,7 +35,7 @@ public final class FinanceSettlementDtos {
String inboundCode, Long containerId, String containerNo,
LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, boolean settlementIncluded, String sourceStatus,
String reviewStatus, String sourceStatus,
boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
@@ -0,0 +1,19 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
public final class ReceivableStatisticsDtos {
private ReceivableStatisticsDtos() {}
public record ReceivableStatisticsRow(long customerId, String customerName, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding) {}
public record ReceivableStatisticsResponse(YearMonth statisticsMonth, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding, long totalCustomers,
int page, int pageSize,
List<ReceivableStatisticsRow> records) {}
}
@@ -15,6 +15,12 @@ public final class ReconciliationStatementDtos {
public record ReconciliationActionRequest(String action, String reason, int expectedVersion,
List<StatementAdjustmentLineRequest> adjustmentLines) {}
public record CustomerConfirmationRequest(int expectedVersion) {}
public record CustomerConfirmationResult(long statementId, String confirmationStatus,
long confirmedBy, String confirmedByName,
LocalDateTime confirmedAt, int version) {}
public record StatementAdjustmentLineRequest(long statementLineId, BigDecimal adjustedAmount,
String reason) {}
@@ -23,6 +29,8 @@ public final class ReconciliationStatementDtos {
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
long createdBy, String createdByName, LocalDateTime createdAt,
long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark,
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
@@ -0,0 +1,53 @@
package com.baiyue.finance.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
@Component
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private final Clock clock;
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
}
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
jdbc.update(
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
}
}
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
@@ -25,6 +26,7 @@ import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
@@ -45,6 +47,7 @@ public class FinanceExportProviderService {
public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL";
public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST";
public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL";
public static final String RECEIVABLE_STATISTICS = "FINANCE_RECEIVABLE_STATISTICS";
private static final int SCHEMA_VERSION = 1;
private static final int MAX_BATCH_SIZE = 500;
@@ -52,9 +55,10 @@ public class FinanceExportProviderService {
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
private static final Set<String> TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER,
FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST,
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL);
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL,
RECEIVABLE_STATISTICS);
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
private static final Set<String> LEDGER_TYPES = Set.of("FEE_INCLUDED", "FEE_EXCLUDED", "RECONCILIATION_CREATED",
private static final Set<String> LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED",
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
@@ -76,7 +80,7 @@ public class FinanceExportProviderService {
column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14),
column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14),
column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16),
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14), column("settlementIncluded", "参与结算", ExportColumnType.BOOLEAN, 12),
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14),
column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16));
private static final List<ExportColumn> STATEMENT_COLUMNS = List.of(
column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
@@ -101,12 +105,20 @@ public class FinanceExportProviderService {
column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16));
private static final List<ExportColumn> RECEIVABLE_STATISTICS_COLUMNS = List.of(
column("statisticsMonth", "统计月份", ExportColumnType.STRING, 14),
column("customerName", "应收单位", ExportColumnType.STRING, 28),
column("currency", "币种", ExportColumnType.STRING, 10),
column("totalReceivable", "应收总额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalReceived", "已收合计(CNY)", ExportColumnType.DECIMAL, 18),
column("totalOutstanding", "余额未收(CNY)", ExportColumnType.DECIMAL, 18));
private final FinanceCustomerAccountService accountService;
private final FinancePhase1OperationService operationService;
private final FinanceQueryService queryService;
private final ReconciliationStatementService reconciliationService;
private final FinanceSettlementService settlementService;
private final ReceivableStatisticsService receivableStatisticsService;
private final ObjectMapper objectMapper;
private final String internalServiceToken;
private byte[] snapshotSigningKey;
@@ -116,6 +128,7 @@ public class FinanceExportProviderService {
FinanceQueryService queryService,
ReconciliationStatementService reconciliationService,
FinanceSettlementService settlementService,
ReceivableStatisticsService receivableStatisticsService,
ObjectMapper objectMapper,
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
this.accountService = accountService;
@@ -123,6 +136,7 @@ public class FinanceExportProviderService {
this.queryService = queryService;
this.reconciliationService = reconciliationService;
this.settlementService = settlementService;
this.receivableStatisticsService = receivableStatisticsService;
this.objectMapper = objectMapper;
this.internalServiceToken = internalServiceToken;
}
@@ -168,6 +182,7 @@ public class FinanceExportProviderService {
case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p);
case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p);
case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p);
case RECEIVABLE_STATISTICS -> receivableStatisticsRows(p);
default -> throw invalid("Unsupported Finance export type");
};
int from = Math.min(offset, all.size());
@@ -175,12 +190,18 @@ public class FinanceExportProviderService {
return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size());
}
private List<Map<String, Object>> receivableStatisticsRows(Map<String, Object> parameters) {
YearMonth month = statisticsMonth(parameters);
Long customerId = longValue(parameters, "customerId", false);
return receivableStatisticsService.exportRows(month, customerId).stream()
.map(value -> receivableStatisticsRow(month, value)).toList();
}
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String direction = text(p, "direction");
String status = text(p, "status");
String reviewStatus = text(p, "reviewStatus");
Boolean included = bool(p, "settlementIncluded");
Boolean generated = bool(p, "statementGenerated");
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
String keyword = lower(text(p, "keyword"));
@@ -189,13 +210,11 @@ public class FinanceExportProviderService {
FeeSourceDetail detail = detail(item);
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
String actualReview = detail == null ? null : detail.reviewStatus();
Boolean actualIncluded = detail == null ? null : detail.settlementIncluded();
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
LocalDate businessDate = detail == null || detail.businessDate() == null
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
if (included != null && !included.equals(actualIncluded)) continue;
if (generated != null && !generated.equals(actualGenerated)) continue;
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
@@ -298,7 +317,7 @@ public class FinanceExportProviderService {
if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid");
String review = text(p, "reviewStatus");
if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid");
bool(p, "settlementIncluded"); bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status");
}
if (RECONCILIATION_STATEMENT_LIST.equals(type)) {
@@ -313,6 +332,7 @@ public class FinanceExportProviderService {
max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName");
}
}
if (RECEIVABLE_STATISTICS.equals(type)) statisticsMonth(p);
}
private String signSnapshot(String type, ValidatedRequest request) {
@@ -358,7 +378,7 @@ public class FinanceExportProviderService {
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
"reviewStatus", detail == null ? null : detail.reviewStatus(), "settlementIncluded", detail == null ? null : detail.settlementIncluded(),
"reviewStatus", detail == null ? null : detail.reviewStatus(),
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
}
private Map<String, Object> statementRow(ReconciliationDetail value) {
@@ -379,6 +399,11 @@ public class FinanceExportProviderService {
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", summary.status());
}
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
"currency", value.currency(), "totalReceivable", value.totalReceivable(),
"totalReceived", value.totalReceived(), "totalOutstanding", value.totalOutstanding());
}
private static List<ExportColumn> columns(String type) {
return switch (type) {
@@ -388,6 +413,7 @@ public class FinanceExportProviderService {
case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS;
case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS;
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS;
case RECEIVABLE_STATISTICS -> RECEIVABLE_STATISTICS_COLUMNS;
default -> throw invalid("Unsupported Finance export type");
};
}
@@ -395,11 +421,12 @@ public class FinanceExportProviderService {
return switch (type) {
case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status");
case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate");
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "settlementIncluded", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo");
case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo");
case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName");
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId");
case RECEIVABLE_STATISTICS -> Set.of("month", "customerId");
default -> throw invalid("Unsupported Finance export type");
};
}
@@ -452,6 +479,12 @@ public class FinanceExportProviderService {
try { return LocalDate.parse(value); }
catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); }
}
private static YearMonth statisticsMonth(Map<String, Object> values) {
String value = text(values, "month");
if (value == null) throw invalid("month is required");
try { return YearMonth.parse(value); }
catch (RuntimeException exception) { throw invalid("month must use YYYY-MM"); }
}
private static void range(Map<String, Object> values, String fromKey, String toKey) {
LocalDate from = date(values, fromKey), to = date(values, toKey);
if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey);
@@ -94,8 +94,10 @@ public class FinanceFeeSourceEventService {
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
FinanceFeeSourceEvent.Payload payload = event.payload();
var response = payload.inboundId() != null
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"),
positive(payload.feeId(), "feeId"), payload.feeVersion())
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"),
positive(payload.feeId(), "feeId"), payload.feeVersion());
if (response == null || response.data() == null || !"200".equals(response.code())) {
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
}
@@ -120,7 +122,7 @@ public class FinanceFeeSourceEventService {
long feeId = positive(event.payload().feeId(), "feeId");
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
if (!"APPROVED".equals(value.reviewStatus())) throw error("FIN-EVT-009", "费用未审核通过");
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
throw error("FIN-EVT-015", "费用来源尚未发布");
}
@@ -128,11 +130,17 @@ public class FinanceFeeSourceEventService {
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("PAYABLE".equals(value.direction())) {
if (value.partnerId() == null || value.partnerId() <= 0 || blank(value.partnerName())) {
if (value.supplierId() == null || value.supplierId() <= 0
|| blank(value.supplierName()) || blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
}
String category = supplierValidator.categoryFor(value.feeItemCode(), value.feeItemName());
supplierValidator.requireActive(value.partnerId(), category);
var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory());
if (!value.supplierName().equals(supplier.name())) {
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
}
} else if (value.supplierId() != null || !blank(value.supplierName())
|| !blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
}
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
@@ -7,7 +7,8 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
@@ -89,7 +90,7 @@ public class FinancePhase1OperationService {
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
String direction, String sourceStatus,
String reviewStatus, Boolean settlementIncluded,
String reviewStatus,
Boolean statementGenerated, LocalDate businessDateFrom,
LocalDate businessDateTo, String keyword) {
page = page(page); pageSize = pageSize(pageSize);
@@ -110,7 +111,6 @@ public class FinancePhase1OperationService {
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
if (settlementIncluded != null) { where.append(" AND f.settlement_included=?"); args.add(settlementIncluded); }
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
@@ -120,9 +120,8 @@ public class FinancePhase1OperationService {
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.partnerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
for (int i = 0; i < 7; i++) args.add(normalizedKeyword);
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
}
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
List<Object> queryArgs = new ArrayList<>(args);
@@ -148,7 +147,7 @@ public class FinancePhase1OperationService {
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request);
if (replay != null) return getFinanceFeeSource(replay);
String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED";
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status='PENDING'",
review, request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
@@ -158,19 +157,68 @@ public class FinancePhase1OperationService {
}
@Transactional
public FeeSourceDetail updateFeeSourceSettlementInclusion(long feeSourceId, long operatorId, String key, SettlementInclusionRequest request) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "参与结算请求无效");
public FeeSourceDetail editFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceEditRequest request, boolean adjustment) {
if (request == null || request.expectedVersion() < 0 || request.amount() == null
|| request.amount().signum() <= 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, "PUT", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/settlement-inclusion", request);
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
Long replay = reserve(before.customerId(), operatorId, operation, key,
adjustment ? "POST" : "PUT", path, request);
if (replay != null) return getFinanceFeeSource(replay);
if (request.included() && !"APPROVED".equals(before.reviewStatus())) throw error("FIN-FEE-409", "只有审核通过的费用来源可参与结算");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成对账单的费用来源不可修改参与结算状态");
int changed = jdbc.update("UPDATE fin_fee_source SET settlement_included=?,settlement_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
request.included(), request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许修改参与结算状态");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
}
int changed = jdbc.update("UPDATE fin_fee_source SET amount=?,review_status='DRAFT',review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
request.amount(), feeSourceId, request.expectedVersion(), requiredStatus);
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许编辑或调整");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, request.included() ? "FEE_INCLUDED" : "FEE_EXCLUDED", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, feeSourceId);
audit(after.customerId(), feeSourceId, adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@Transactional
public FeeSourceDetail submitFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_SUBMIT",
"PENDING", List.of("DRAFT", "REJECTED"), false);
}
@Transactional
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
}
private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request, String operation,
String target, List<String> allowedReviews,
boolean voidSource) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, operation, key, "POST",
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
int changed = voidSource
? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
feeSourceId, request.expectedVersion(), before.reviewStatus())
: jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
target, feeSourceId, request.expectedVersion(), before.reviewStatus());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本已变化");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, operation, request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@@ -183,9 +231,9 @@ public class FinancePhase1OperationService {
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
rs.getString("review_reason"), rs.getString("settlement_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
@@ -198,11 +246,11 @@ public class FinancePhase1OperationService {
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")), nullableLong(rs, "order_id"),
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, unitPrice, amount, rs.getString("currency_code"),
defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
@@ -72,7 +72,7 @@ public class FinanceQueryService {
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
"AND f.review_status='APPROVED' AND f.amount>0 " +
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
@@ -126,13 +126,15 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
@@ -140,7 +142,7 @@ public class FinanceQueryService {
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
validateOptionalCustomer(customerId);
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.customer_confirmation_status,b.customer_confirmed_by,b.customer_confirmed_by_name,b.customer_confirmed_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
"COALESCE(p.credit_days,30) credit_days," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," +
@@ -160,7 +162,10 @@ public class FinanceQueryService {
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"),
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"), rs.getString("remark"),
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getString("remark"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
}
@@ -168,10 +173,12 @@ public class FinanceQueryService {
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,customer_confirmation_status,customer_confirmed_by,customer_confirmed_by_name,customer_confirmed_at,created_by,created_by_name,created_at,updated_by,updated_by_name,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), time(rs, "customer_confirmed_at"),
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
@@ -16,10 +16,8 @@ import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */
@Service
@@ -82,13 +80,6 @@ public class FinanceService {
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
}
@Transactional
public Map<String, Object> issueBillById(long operatorId, long billId, int expectedVersion, String idempotencyKey) {
Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
if (tenantId == null) throw bad("账单不存在");
return issueBill(tenantId, operatorId, billId, expectedVersion, idempotencyKey);
}
public List<Map<String, Object>> listReceipts(Long tenantId) {
String sql = "SELECT r.id,r.tenant_id,r.receipt_no,r.received_amount,r.received_at,r.payment_method,r.voucher_no,r.voucher_file_ids,r.status,rb.bill_id,rb.applied_amount FROM fin_receipt r LEFT JOIN fin_receipt_bill rb ON rb.receipt_id=r.id";
return tenantId == null ? jdbc.queryForList(sql + " ORDER BY r.received_at DESC,r.id DESC") : jdbc.queryForList(sql + " WHERE r.tenant_id=? ORDER BY r.received_at DESC,r.id DESC", tenantId);
@@ -114,91 +105,6 @@ public class FinanceService {
return customerTerms(tenantId);
}
@Transactional
public Map<String, Object> createBill(long tenantId, long operatorId, String customerName, BillRequest request,
String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey, request);
if (replay != null) return bill(tenantId, replay);
if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细");
request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); });
validateBillSources(tenantId, request.lines());
BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() < 0) throw bad("账单金额不能为负数");
Object credit = customerTerms(tenantId).get("credit_days");
if (credit == null) credit = customerTerms(tenantId).get("creditDays");
int days = credit == null ? 30 : ((Number) credit).intValue();
LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate();
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("BILL")); ps.setLong(3, request.subjectId()); ps.setString(4, customerName);
ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total);
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setString(12, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(13, operatorId); ps.setString(14, FinanceOperatorSnapshot.currentName(operatorId)); return ps;
}, holder);
long billId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo("BILL", billId), billId);
for (BillLine line : request.lines()) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
tenantId, billId, line.sourceType(), line.sourceId(), line.sourceVersion(), line.sourceNo(), line.orderId(), line.inboundId(), line.feeItemCode(), line.feeItemName(), line.amount());
}
complete(tenantId, operatorId, "BILL_CREATE", idempotencyKey, billId);
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId);
}
/** Initial statements may only snapshot approved, included Finance sources; the client cannot choose an amount. */
private void validateBillSources(long tenantId, List<BillLine> lines) {
Set<String> seen = new HashSet<>();
for (BillLine line : lines) {
if (!seen.add(line.sourceId() + ":" + line.sourceVersion())) throw bad("同一费用来源版本不能重复加入账单");
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT customer_id,source_fee_version,direction,currency_code,amount,status,review_status,settlement_included "
+ "FROM fin_fee_source WHERE id=? FOR UPDATE", line.sourceId());
if (rows.size() != 1) throw bad("费用来源不存在");
Map<String, Object> source = rows.get(0);
if (number(source, "customer_id") != tenantId
|| number(source, "source_fee_version") != line.sourceVersion()
|| !"RECEIVABLE".equals(source.get("direction"))
|| !"CNY".equals(source.get("currency_code"))
|| !"AVAILABLE".equals(source.get("status"))
|| !"APPROVED".equals(source.get("review_status"))
|| !truth(source.get("settlement_included"))) {
throw bad("费用来源未审核、未参与结算或不属于当前客户");
}
BigDecimal sourceAmount = decimal(source, "amount");
if (sourceAmount.compareTo(line.amount()) != 0) throw bad("账单金额必须等于费用来源金额");
Integer used = jdbc.queryForObject(
"SELECT COUNT(*) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id "
+ "WHERE l.tenant_id=? AND l.source_id=? AND l.source_version=? AND b.status<>'VOIDED'",
Integer.class, tenantId, line.sourceId(), line.sourceVersion());
if (used != null && used > 0) throw bad("费用来源已被有效账单占用");
}
}
private static long number(Map<String, Object> row, String key) {
Object value = row.get(key);
return value instanceof Number n ? n.longValue() : Long.parseLong(String.valueOf(value));
}
private static BigDecimal decimal(Map<String, Object> row, String key) {
Object value = row.get(key);
return value instanceof BigDecimal decimal ? decimal : new BigDecimal(String.valueOf(value));
}
private static boolean truth(Object value) {
return Boolean.TRUE.equals(value) || "1".equals(String.valueOf(value)) || "true".equalsIgnoreCase(String.valueOf(value));
}
@Transactional
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion));
if (replay != null) return bill(tenantId, replay);
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?",
LocalDateTime.now(), operatorId, operatorId, FinanceOperatorSnapshot.currentName(operatorId), billId, tenantId, expectedVersion);
if (updated != 1) throw bad("账单不存在或已出账");
complete(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, billId);
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId);
}
@Transactional
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request);
@@ -271,8 +177,7 @@ public class FinanceService {
String supplierName = null;
if (request.supplierId() != null) {
if (request.supplierId() <= 0) throw bad("供应商ID无效");
String category = supplierValidator.categoryFor(request.feeItemCode(), request.feeItemName());
supplierName = supplierValidator.requireActive(request.supplierId(), category).name();
throw bad("人工成本缺少显式供应商类别,无法校验供应商");
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
throw bad("供应商名称必须来自Warehouse供应商主数据");
}
@@ -393,8 +298,6 @@ public class FinanceService {
private static String requestPath(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
case "BILL_CREATE" -> "/api/platform/finance/v1/bills";
case "BILL_ISSUE" -> "/api/platform/finance/v1/bills/{billId}/issue";
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
@@ -437,8 +340,6 @@ public class FinanceService {
private void replaceDraftLines(long tenantId, long billId, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); }
private void insertLines(long tenantId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); }
public record BillRequest(String billNo, long subjectId, LocalDate billDate, String remark, List<BillLine> lines) {}
public record BillLine(String sourceType, long sourceId, int sourceVersion, String sourceNo, Long orderId, Long inboundId, String feeItemCode, String feeItemName, BigDecimal amount) {}
public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
public record ReceiptAllocation(long billId, BigDecimal amount) {}
public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List<String> voucherFileIds, String remark) {}
@@ -16,6 +16,7 @@ import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -63,13 +64,22 @@ public class FinanceSettlementService {
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
private final FinanceDocumentNumberGenerator documentNumbers;
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
}
@Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
this.documentNumbers = documentNumbers;
}
@Transactional
@@ -101,7 +111,7 @@ public class FinanceSettlementService {
if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细");
LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNo("AR"), "RECEIVABLE",
long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE",
text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(),
date(bill, "bill_date").format(DateTimeFormatter.ofPattern("yyyy-MM")), CNY, total,
generatedDate, date(bill, "due_date"), operatorId);
@@ -131,12 +141,10 @@ public class FinanceSettlementService {
args.addAll(ids);
List<Map<String, Object>> sources = jdbc.queryForList(
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus')) review_status," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded')) settlement_included," +
"CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId'))," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerId'))) AS UNSIGNED) supplier_id," +
"COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName'))," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.partnerName'))) supplier_name " +
"review_status," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED) supplier_id," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierName')) supplier_name," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierCategory')) supplier_category " +
"FROM fin_fee_source WHERE id IN (" + placeholders + ") FOR UPDATE", args.toArray());
if (sources.size() != ids.size() || sources.stream().anyMatch(row -> number(row, "customer_id").longValue() != customerId)) {
throw error("FIN_404", "费用来源不存在或不属于客户");
@@ -145,8 +153,8 @@ public class FinanceSettlementService {
Long supplierId = nullableNumber(source, "supplier_id");
if (supplierId == null || supplierId != request.supplierId()) throw error("FIN_409", "应付来源必须属于同一供应商");
if (!"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status"))
|| !"APPROVED".equals(text(source, "review_status")) || !truth(source.get("settlement_included"))) {
throw error("FIN_409", "费用来源未审核、未参与结算或状态不可用");
|| !"APPROVED".equals(text(source, "review_status")) || decimal(source, "amount").signum() <= 0) {
throw error("FIN_409", "费用来源未审核、金额无效或状态不可用");
}
if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY");
Integer used = jdbc.queryForObject(
@@ -156,15 +164,17 @@ public class FinanceSettlementService {
}
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
Set<String> supplierCategories = sources.stream()
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
if (sources.stream().anyMatch(row -> !StringUtils.hasText(text(row, "supplier_category")))) {
throw error("FIN_409", "应付费用来源缺少供应商类别快照");
}
Set<String> supplierCategories = sources.stream().map(row -> text(row, "supplier_category"))
.collect(java.util.stream.Collectors.toSet());
if (supplierCategories.size() != 1) throw error("FIN_409", "应付结算费用来源必须属于同一供应商类别");
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategories.iterator().next()).name();
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
LocalDate generatedDate = LocalDate.now();
long settlementId = insertSettlement(customerId, documentNo("AP"), "PAYABLE", customerName,
long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName,
request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")),
CNY, total, generatedDate, request.dueDate(), operatorId);
for (Map<String, Object> source : sources) {
@@ -185,7 +195,7 @@ public class FinanceSettlementService {
SettlementSummary summary = summary(row);
List<SettlementLine> lines = jdbc.queryForList(
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
@@ -330,7 +340,7 @@ public class FinanceSettlementService {
jdbc.update("INSERT INTO fin_settlement_payment(tenant_id,settlement_id,transaction_type,transaction_no," +
"transaction_date,amount,currency_code,method,reference_no,voucher_file_ids,remark,created_by,created_by_name,created_at) " +
"VALUES(?,?,'PAYMENT',?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))",
customerId, settlementId, documentNo("PAY"), request.transactionDate(), request.amount(), CNY,
customerId, settlementId, transactionNo(), request.transactionDate(), request.amount(), CNY,
request.method(), blank(request.referenceNo()), json(request.voucherFileIds()), blank(request.remark()),
operatorId, FinanceOperatorSnapshot.currentName(operatorId));
}
@@ -375,11 +385,11 @@ public class FinanceSettlementService {
private String payableSupplierCategory(long customerId, long settlementId) {
Set<String> categories = jdbc.queryForList(
"SELECT f.fee_item_code,f.fee_item_name FROM fin_settlement_line sl " +
"SELECT JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierCategory')) supplier_category FROM fin_settlement_line sl " +
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=?",
customerId, settlementId).stream()
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
.map(row -> text(row, "supplier_category"))
.collect(java.util.stream.Collectors.toSet());
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
return categories.iterator().next();
@@ -454,17 +464,16 @@ public class FinanceSettlementService {
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
Long supplierId = jsonLong(snapshot, "supplierId");
if (supplierId == null) supplierId = jsonLong(snapshot, "partnerId");
if (supplierId == null) throw error("FIN_409", "应付结算缺少供应商快照");
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
supplierId, firstText(jsonText(snapshot, "supplierName"),
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
supplierId, jsonText(snapshot, "supplierName"),
nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"),
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
jsonBoolean(snapshot, "settlementIncluded"), text(row, "status"), true);
quantity, unitPrice, amount, text(row, "currency_code"), text(row, "review_status"),
text(row, "status"), true);
}
private SettlementTransaction transaction(Map<String, Object> row) {
@@ -509,8 +518,8 @@ public class FinanceSettlementService {
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
}
private String documentNo(String prefix) {
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
private String transactionNo() {
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
}
@@ -41,21 +41,6 @@ public class FinanceSupplierValidator {
}
}
public String categoryFor(String feeItemCode, String feeItemName) {
String value = ((feeItemCode == null ? "" : feeItemCode) + " "
+ (feeItemName == null ? "" : feeItemName)).toUpperCase(Locale.ROOT);
if (containsAny(value, "TRUCK", "TRAILER", "DRAYAGE", "拖车", "卡车")) return "TRUCKING";
if (containsAny(value, "CUSTOMS", "CLEARANCE", "DECLARATION", "报关", "清关")) return "CUSTOMS";
if (containsAny(value, "CERTIFICATE", "CERTIFICATION", "CERT_", "认证", "证书")) return "CERTIFICATE";
if (containsAny(value, "BOOKING", "FREIGHT", "OCEAN", "SHIPPING", "订舱", "海运", "运费")) return "BOOKING";
throw new FinanceEventException("FIN_409", "费用来源缺少可验证的供应商类别");
}
private static boolean containsAny(String value, String... candidates) {
for (String candidate : candidates) if (value.contains(candidate)) return true;
return false;
}
private static String normalizeCategory(String category) {
String normalized = category == null ? "" : category.trim().toUpperCase(Locale.ROOT);
if (!CATEGORIES.contains(normalized)) {
@@ -0,0 +1,103 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
@Service
public class ReceivableStatisticsService {
private static final String CURRENCY = "CNY";
private final JdbcTemplate jdbc;
public ReceivableStatisticsService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) {
validate(month, customerId, page, pageSize);
List<ReceivableStatisticsRow> all = rows(month, customerId);
Totals totals = totals(all);
int from = (int) Math.min((long) (page - 1) * pageSize, all.size());
int to = Math.min(from + pageSize, all.size());
return new ReceivableStatisticsResponse(month, CURRENCY, totals.receivable(), totals.received(),
totals.outstanding(), all.size(), page, pageSize, List.copyOf(all.subList(from, to)));
}
public List<ReceivableStatisticsRow> exportRows(YearMonth month, Long customerId) {
validate(month, customerId, 1, 1);
List<ReceivableStatisticsRow> rows = rows(month, customerId);
totals(rows);
return rows;
}
private List<ReceivableStatisticsRow> rows(YearMonth month, Long customerId) {
LocalDate from = month.atDay(1);
LocalDate to = month.plusMonths(1).atDay(1);
StringBuilder sql = new StringBuilder("""
SELECT tenant_id, customer_name_snapshot,
SUM(total_amount) total_receivable,
SUM(paid_amount) total_received,
SUM(outstanding_amount) total_outstanding
FROM fin_settlement
WHERE direction='RECEIVABLE'
AND status<>'VOIDED'
AND currency_code='CNY'
AND generated_date>=?
AND generated_date<?
""");
List<Object> args = new ArrayList<>(List.of(from, to));
if (customerId != null) {
sql.append(" AND tenant_id=?");
args.add(customerId);
}
sql.append(" GROUP BY tenant_id,customer_name_snapshot ORDER BY customer_name_snapshot,tenant_id");
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
rs.getBigDecimal("total_outstanding"))), args.toArray());
}
private static Totals totals(List<ReceivableStatisticsRow> rows) {
BigDecimal receivable = BigDecimal.ZERO;
BigDecimal received = BigDecimal.ZERO;
BigDecimal outstanding = BigDecimal.ZERO;
for (ReceivableStatisticsRow row : rows) {
checked(row);
receivable = receivable.add(row.totalReceivable());
received = received.add(row.totalReceived());
outstanding = outstanding.add(row.totalOutstanding());
}
if (receivable.compareTo(received.add(outstanding)) != 0) inconsistent();
return new Totals(receivable, received, outstanding);
}
private static ReceivableStatisticsRow checked(ReceivableStatisticsRow row) {
if (row.customerId() <= 0 || row.customerName() == null || row.customerName().isBlank()
|| row.totalReceivable() == null || row.totalReceived() == null || row.totalOutstanding() == null
|| row.totalReceivable().signum() < 0 || row.totalReceived().signum() < 0
|| row.totalOutstanding().signum() < 0
|| row.totalReceivable().compareTo(row.totalReceived().add(row.totalOutstanding())) != 0) {
inconsistent();
}
return row;
}
private static void validate(YearMonth month, Long customerId, int page, int pageSize) {
if (month == null) throw new FinanceEventException("FIN-STAT-400", "统计月份不能为空");
if (customerId != null && customerId <= 0) throw new FinanceEventException("FIN-STAT-400", "客户ID必须为正整数");
if (page <= 0 || pageSize <= 0 || pageSize > 500) throw new FinanceEventException("FIN-STAT-400", "分页参数无效");
}
private static void inconsistent() {
throw new FinanceEventException("FIN-STAT-001", "应收统计金额勾稽异常,请核对正式结算数据");
}
private record Totals(BigDecimal receivable, BigDecimal received, BigDecimal outstanding) {}
}
@@ -1,6 +1,8 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationResult;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
@@ -12,6 +14,7 @@ import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -33,7 +36,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
public class ReconciliationStatementService {
@@ -42,10 +44,18 @@ public class ReconciliationStatementService {
private static final Set<String> REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceDocumentNumberGenerator documentNumbers;
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
}
@Autowired
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceDocumentNumberGenerator documentNumbers) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.documentNumbers = documentNumbers;
}
@Transactional
@@ -61,6 +71,7 @@ public class ReconciliationStatementService {
if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源");
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
String statementNo = documentNumbers.next("REC");
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
@@ -68,7 +79,7 @@ public class ReconciliationStatementService {
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, request.customerId());
statement.setString(2, "REC-PENDING-" + UUID.randomUUID());
statement.setString(2, statementNo);
statement.setLong(3, subjectId);
statement.setString(4, customer.name());
statement.setString(5, "CNY");
@@ -85,7 +96,6 @@ public class ReconciliationStatementService {
}, holder);
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
long statementId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
for (Source source : sources) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY')",
request.customerId(), statementId, "FIRSTLEG_FEE", source.id(), source.sourceVersion(),
@@ -123,11 +133,18 @@ public class ReconciliationStatementService {
statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
if ("ISSUE".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
before.customerId(), statementId, before.customerId());
} else if ("VOID".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
before.customerId(), statementId, before.customerId());
} else if ("REOPEN".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
}
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
@@ -149,7 +166,7 @@ public class ReconciliationStatementService {
BigDecimal.class, before.customerId(), statementId);
if (total == null) total = BigDecimal.ZERO;
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId,
before.customerId(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
@@ -175,22 +192,60 @@ public class ReconciliationStatementService {
"SELECT r.id,r.received_at,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
(rs, row) -> payment(rs), customerId, statementId);
List<ReconciliationRevision> revisions = jdbc.query(
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND before_data IS NOT NULL ORDER BY created_at,id",
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id",
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
bill.customerConfirmedAt(),
bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(),
bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
@Transactional
public CustomerConfirmationResult confirmForCustomer(long customerId, long operatorId, long statementId,
String key, CustomerConfirmationRequest request) {
if (customerId <= 0 || operatorId <= 0 || statementId <= 0 || request == null || request.expectedVersion() < 0) {
throw error("FIN_400", "客户确认请求无效");
}
Bill before = bill(customerId, statementId);
Long replay = reserve(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key,
"POST", "/api/client/finance/v1/statements/" + statementId + "/confirm", request);
if (replay != null) return customerConfirmation(customerId, replay);
if (!Set.of("ISSUED", "PARTIALLY_PAID", "PAID").contains(before.status())) {
throw error("FIN_409", "仅已出账对账单允许客户确认");
}
if (!"PENDING_CONFIRMATION".equals(before.customerConfirmationStatus())) {
throw error("FIN_409", "对账单已确认或当前无需确认");
}
String operatorName = FinanceOperatorSnapshot.requiredName(operatorId);
int changed = jdbc.update("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED',customer_confirmed_at=CURRENT_TIMESTAMP(3),customer_confirmed_by=?,customer_confirmed_by_name=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND customer_confirmation_status='PENDING_CONFIRMATION' AND version=?",
operatorId, operatorName, operatorId, operatorName, statementId, customerId, request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
CustomerConfirmationResult after = customerConfirmation(customerId, statementId);
audit(customerId, statementId, "RECONCILIATION_CUSTOMER_CONFIRM", null, before, after, operatorId);
complete(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key, statementId);
return after;
}
private CustomerConfirmationResult customerConfirmation(long customerId, long statementId) {
Bill current = bill(customerId, statementId);
if (!"CONFIRMED".equals(current.customerConfirmationStatus()) || current.customerConfirmedBy() == null
|| current.customerConfirmedAt() == null) {
throw error("FIN_409", "对账单尚未完成客户确认");
}
return new CustomerConfirmationResult(current.id(), current.customerConfirmationStatus(),
current.customerConfirmedBy(), current.customerConfirmedByName(), current.customerConfirmedAt(), current.version());
}
public ReconciliationDetail detailForPlatform(long statementId) {
return detail(bill(statementId).customerId(), statementId);
}
private List<Source> eligibleSources(CreateReconciliationRequest request) {
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND review_status='APPROVED' AND amount>0 AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.publicationStatus'))='PUBLISHED' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
List<Map<String, Object>> selected = jdbc.queryForList(
@@ -453,6 +508,8 @@ public class ReconciliationStatementService {
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
rs.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), timestamp(rs, "customer_confirmed_at"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getString("updated_by_name"),
@@ -480,8 +537,9 @@ public class ReconciliationStatementService {
return rs.wasNull() ? null : value;
}
private static String stableNo(long id) {
return "REC-" + String.format(Locale.ROOT, "%020d", id);
private static LocalDateTime timestamp(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toLocalDateTime();
}
private static FinanceEventException error(String code, String message) {
@@ -496,6 +554,8 @@ public class ReconciliationStatementService {
String feeItemName, BigDecimal amount) {}
private record Bill(long id, long customerId, String number, String customerName, String status,
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
int creditDays, String remark, long createdBy, String createdByName,
LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {}
}
@@ -34,15 +34,11 @@ CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'review_status',
"VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回' AFTER source_snapshot");
CALL finance_fee_source_add_column('fin_fee_source', 'review_reason',
"VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因' AFTER review_status");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_included',
"TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'settlement_reason',
"VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明' AFTER settlement_included");
CALL finance_fee_source_add_column('fin_fee_source', 'operation_version',
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号' AFTER settlement_reason");
"INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号' AFTER review_reason");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
@@ -62,8 +58,7 @@ UPDATE fin_fee_source
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
updated_at = COALESCE(updated_at, created_at),
review_status = COALESCE(review_status, 'PENDING'),
settlement_included = COALESCE(settlement_included, 0),
review_status = COALESCE(review_status, 'DRAFT'),
operation_version = COALESCE(operation_version, 0);
UPDATE fin_inbox_event
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
@@ -88,7 +83,7 @@ BEGIN
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','review_status','review_reason',
'settlement_included','settlement_reason','operation_version','updated_at')) <> 13 THEN
'operation_version','updated_at')) <> 11 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
+9 -4
View File
@@ -20,11 +20,9 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'Finance费用审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'Finance费用状态:DRAFT草稿、PENDING待审核、APPROVED已通过、REJECTED已驳回',
review_reason VARCHAR(500) NULL COMMENT 'Finance费用审核说明或驳回原因',
settlement_included TINYINT NOT NULL DEFAULT 0 COMMENT '是否已人工确认参与结算:0否、1是',
settlement_reason VARCHAR(500) NULL COMMENT '参与或禁止结算的人工说明',
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用审核和结算参与操作版本号',
operation_version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '费用编辑、提交、审核、调整和作废操作版本号',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
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 '来源状态最后更新时间,数据修改时自动刷新',
@@ -284,6 +282,13 @@ CREATE TABLE IF NOT EXISTS fin_idempotency_record (
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
CREATE TABLE IF NOT EXISTS fin_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如REC:20260818',
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
CREATE TABLE IF NOT EXISTS fin_audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -52,7 +52,7 @@ class CustomerAccountSummaryBehaviorTest {
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.settlement_included=1")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
.contains("b.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
@@ -24,10 +24,11 @@ class FinanceContractTest {
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
assertTrue(service.contains("validateBillSources(tenantId, request.lines())"));
assertTrue(service.contains("账单金额必须等于费用来源金额"));
assertTrue(service.contains("source_fee_version") && service.contains("settlement_included"));
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
assertFalse(controller.contains("@PostMapping(\"/bills\")"));
assertFalse(controller.contains("/bills/{billId}/issue"));
assertFalse(service.contains("BILL_CREATE"));
assertFalse(service.contains("BILL_ISSUE"));
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
@@ -38,7 +39,6 @@ class FinanceContractTest {
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance"));
assertTrue(unified.contains("assert_finance_permissions"));
assertTrue(unified.contains("platform:finance:bill:issue"));
assertTrue(unified.contains("merchant:finance:statement:view"));
}
}
@@ -37,7 +37,6 @@ class FinanceFeeSourceEventServiceTest {
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("TRUCKING");
lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE"));
@@ -71,20 +70,19 @@ class FinanceFeeSourceEventServiceTest {
givenNewEvent();
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
null, null, null, 41L, 61L, "C-41", 7, "客户", 71L, "现有往来单位",
61L, "TRUCKING", "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED", true,
"TRUCKING", 61L, "拖车费", "PAYABLE", LocalDate.of(2026, 8, 11),
"CNY", new BigDecimal("300.00"), "APPROVED", "PUBLISHED",
LocalDateTime.of(2026, 8, 11, 1, 0), null, null, null, null,
"source-hash-payable", 1);
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot));
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).categoryFor("TRUCKING", "拖车费");
verify(supplierValidator).requireActive(71L, "TRUCKING");
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
.contains("\"partnerId\":71", "\"partnerName\":\"现有往来单位\"");
.contains("\"supplierId\":71", "\"supplierName\":\"现有往来单位\"", "\"supplierCategory\":\"TRUCKING\"");
}
@Test
@@ -202,9 +200,9 @@ class FinanceFeeSourceEventServiceTest {
String publicationStatus, BigDecimal amount) {
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
inboundId == null ? null : "IB-21", 51L, containerId, null,
containerId == null ? null : "C-41", customerId, "客户", null, null, 61L, "CUSTOMS",
containerId == null ? null : "C-41", customerId, "客户", null, null, null, 61L, "CUSTOMS",
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
"APPROVED", publicationStatus, true, LocalDateTime.of(2026, 8, 11, 1, 0),
"APPROVED", publicationStatus, LocalDateTime.of(2026, 8, 11, 1, 0),
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
}
@@ -48,23 +48,23 @@ class FinancePhase1OperationBehaviorTest {
FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService);
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);
var action = new FinancePhase1Dtos.FeeSourceActionRequest("提交审核", 1);
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED", true,
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion);
controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
eq("AVAILABLE"), eq("APPROVED"), eq(true), eq(false), any(), any(), eq("IN-9"));
eq("AVAILABLE"), eq("APPROVED"), eq(false), any(), any(), eq("IN-9"));
verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
verify(operationService).updateFeeSourceSettlementInclusion(88L, 71L, "include-key", inclusion);
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
}
@Test
@@ -141,14 +141,13 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("settlement_included")).thenReturn(true);
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
});
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
true, false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page()).satisfies(meta -> {
assertThat(meta.page()).isEqualTo(2);
@@ -163,7 +162,6 @@ class FinancePhase1OperationBehaviorTest {
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
assertThat(source.settlementIncluded()).isTrue();
assertThat(source.statementGenerated()).isFalse();
});
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
@@ -201,14 +199,11 @@ class FinancePhase1OperationBehaviorTest {
}
@Test
void rejectedFeeCannotBeIncludedInSettlement() throws Exception {
void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, 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");
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
}
@Test
@@ -222,33 +217,32 @@ class FinancePhase1OperationBehaviorTest {
assertThat(detail.customerId()).isEqualTo(9L);
assertThat(detail.currency()).isEqualTo("CNY");
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
assertThat(detail.settlementIncluded()).isTrue();
}
@Test
void approvedFeeCanBeIncludedWithExpectedVersion() throws Exception {
void rejectedFeeCanBeEditedBackToDraftWithExpectedVersion() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName())
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
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);
boolean included = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet("APPROVED", included, included ? 2 : 1), 0));
boolean changed = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet(changed ? "DRAFT" : "REJECTED", false, changed ? 2 : 1), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), 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"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
eq("FEE_SOURCE_EDIT"), eq("edit-key"))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FeeSourceDetail result = service.updateFeeSourceSettlementInclusion(88L, 71L, "include-key",
new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入结算", 1));
FeeSourceDetail result = service.editFinanceFeeSource(88L, 71L, "edit-key",
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "更正金额", 1), false);
assertThat(result.settlementIncluded()).isTrue();
assertThat(result.reviewStatus()).isEqualTo("DRAFT");
assertThat(result.operationVersion()).isEqualTo(2);
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET settlement_included="),
eq(true), eq("进入结算"), eq(88L), eq(1));
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET amount="),
eq(new BigDecimal("120.00")), eq(88L), eq(1), eq("REJECTED"));
}
@Test
@@ -316,11 +310,9 @@ class FinancePhase1OperationBehaviorTest {
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 + "}");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
return rs;
}
@@ -27,10 +27,10 @@ class FinancePhase1PermissionContractTest {
"platform:finance:fee-source:review",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
"platform:finance:fee-source:settlement",
assertEndpoint("editFinanceFeeSource", PutMapping.class,
"platform:finance:fee-source:edit",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest.class);
}
private static void assertEndpoint(String methodName,
@@ -11,11 +11,14 @@ import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
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.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -34,6 +37,7 @@ class FinanceReadApiBehaviorTest {
@Mock FinanceFileClient fileClient;
@Mock FirstlegCostSnapshotClient snapshotClient;
@Mock FinanceSettlementService settlementService;
@Mock ReconciliationStatementService reconciliationStatementService;
@Test
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
@@ -42,7 +46,7 @@ class FinanceReadApiBehaviorTest {
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
var response = new ClientFinanceController(service, queryService).receipts(user);
var response = new ClientFinanceController(service, queryService, reconciliationStatementService).receipts(user);
verify(queryService).clientReceipts(23L);
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
@@ -50,7 +54,7 @@ class FinanceReadApiBehaviorTest {
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
new ClientFinanceController(service, queryService).bill(user, 88L);
new ClientFinanceController(service, queryService, reconciliationStatementService).bill(user, 88L);
verify(queryService).statement(23L, 88L, true);
}
@@ -102,7 +106,7 @@ class FinanceReadApiBehaviorTest {
@Test
void clientListsAlwaysUseAuthenticatedCustomerScope() {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
var controller = new ClientFinanceController(service, queryService);
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
controller.bills(user);
controller.statements(user);
@@ -112,4 +116,23 @@ class FinanceReadApiBehaviorTest {
verify(queryService).receivableSettlements(23L, true);
verifyNoMoreInteractions(queryService);
}
@Test
void clientConfirmationUsesOnlyAuthenticatedCustomerAndOperatorScope() {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
CustomerConfirmationRequest request = new CustomerConfirmationRequest(4);
var controller = new ClientFinanceController(service, queryService, reconciliationStatementService);
controller.confirmStatement(user, 88L, "confirm-key", request);
verify(reconciliationStatementService).confirmForCustomer(23L, 7L, 88L, "confirm-key", request);
}
@Test
void clientConfirmationHasIndependentWriteAuthority() throws Exception {
PreAuthorize rule = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class,
long.class, String.class, CustomerConfirmationRequest.class).getAnnotation(PreAuthorize.class);
assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')");
}
}
@@ -10,13 +10,10 @@ import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
@@ -25,45 +22,6 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class FinanceServiceValidationTest {
@Test
void acceptsOnlyApprovedIncludedReceivableSourceAtItsRecordedAmount() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class),
eq(9L), eq(81L), eq(3))).thenReturn(0);
FinanceService service = service(jdbc);
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "validateBillSources", 9L,
List.of(billLine(81L, 3, "125.50"))));
}
@Test
void rejectsClientAmountThatDiffersFromFinanceSourceSnapshot() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50")));
FinanceService service = service(jdbc);
BizException error = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
"validateBillSources", 9L, List.of(billLine(81L, 3, "99.00"))));
assertEquals("账单金额必须等于费用来源金额", error.getMessage());
verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"),
eq(Integer.class), any(), any(), any());
}
@Test
void rejectsSourceThatIsNotApprovedAndIncludedForSettlement() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L)))
.thenReturn(List.of(source(9L, 3, "AVAILABLE", "PENDING", false, "125.50")));
FinanceService service = service(jdbc);
assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service,
"validateBillSources", 9L, List.of(billLine(81L, 3, "125.50"))));
}
@Test
void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() {
var rows = List.of(
@@ -284,15 +242,4 @@ class FinanceServiceValidationTest {
mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class));
}
private static FinanceService.BillLine billLine(long sourceId, int version, String amount) {
return new FinanceService.BillLine("FIRSTLEG_FEE", sourceId, version, null, 31L, 41L,
"FREIGHT", "运费", new BigDecimal(amount));
}
private static Map<String, Object> source(long customerId, int version, String status,
String reviewStatus, boolean included, String amount) {
return Map.of("customer_id", customerId, "source_fee_version", version,
"direction", "RECEIVABLE", "currency_code", "CNY", "amount", new BigDecimal(amount),
"status", status, "review_status", reviewStatus, "settlement_included", included);
}
}
@@ -13,6 +13,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
@@ -542,8 +543,11 @@ class FinanceSettlementBehaviorTest {
FinanceSupplierValidator supplierValidator) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT f.fee_item_code"), anyLong(), anyLong()))
.thenReturn(List.of(Map.of("fee_item_code", "FBA_HEAD_FREIGHT", "fee_item_name", "头程运费")));
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator);
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
}
private static FinanceSupplierValidator supplierValidator() {
@@ -586,7 +590,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("id", 31L), Map.entry("customer_id", customerId), Map.entry("direction", "PAYABLE"),
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_included", "true"), Map.entry("supplier_id", supplierId),
Map.entry("supplier_id", supplierId),
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
@@ -43,7 +43,7 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(16);
assertThat(tableCount).isEqualTo(17);
assertThat(columnCount).isEqualTo(239);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -18,12 +18,9 @@ class FinanceSupplierValidatorTest {
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
@Test
void resolvesSupportedFeeCategories() {
assertThat(validator.categoryFor("TRUCKING", "拖车费")).isEqualTo("TRUCKING");
assertThat(validator.categoryFor("CUSTOMS_CLEARANCE", "报关费")).isEqualTo("CUSTOMS");
assertThat(validator.categoryFor("FBA_HEAD_FREIGHT", "头程运费")).isEqualTo("BOOKING");
assertThat(validator.categoryFor("CERTIFICATE_FEE", "证书费")).isEqualTo("CERTIFICATE");
assertCode(() -> validator.categoryFor("ACPT_UNKNOWN", "操作费"), "FIN_409");
void rejectsMissingOrInvalidExplicitFeeCategory() {
assertCode(() -> validator.requireActive(20L, null), "FIN_409");
assertCode(() -> validator.requireActive(20L, "UNKNOWN"), "FIN_409");
}
@Test
@@ -0,0 +1,39 @@
package com.baiyue.finance;
import com.baiyue.finance.controller.ReceivableStatisticsController;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.ReceivableStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ReceivableStatisticsControllerTest {
@Test
void exposesTheDedicatedReadEndpointAndRejectsNonMonthInput() throws Exception {
ReceivableStatisticsService service = mock(ReceivableStatisticsService.class);
ReceivableStatisticsResponse response = new ReceivableStatisticsResponse(YearMonth.of(2026, 7), "CNY",
BigDecimal.TEN, BigDecimal.ONE, BigDecimal.valueOf(9), 0, 1, 20, List.of());
when(service.query(YearMonth.of(2026, 7), null, 1, 20)).thenReturn(response);
ReceivableStatisticsController controller = new ReceivableStatisticsController(service);
assertThat(ReceivableStatisticsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/finance/v1/receivable-statistics");
assertThat(ReceivableStatisticsController.class.getMethod("query", String.class, Long.class, int.class, int.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:receivable-statistics:list')");
assertThat(controller.query("2026-07", null, 1, 20).data()).isSameAs(response);
verify(service).query(YearMonth.of(2026, 7), null, 1, 20);
assertThatThrownBy(() -> controller.query("2026/07", null, 1, 20))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("yyyy-MM");
}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.ReceivableStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ReceivableStatisticsServiceTest {
@Test
@SuppressWarnings("unchecked")
void aggregatesOnlyTheApprovedFormalCnyDimensionsAndPaginatesCustomers() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
List<ReceivableStatisticsRow> rows = List.of(
row(10001, "客户A", "300.00", "100.00", "200.00"),
row(10002, "客户B", "80.00", "80.00", "0.00"));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(rows);
ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc);
var result = service.query(YearMonth.of(2026, 7), null, 1, 1);
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.totalReceivable()).isEqualByComparingTo("380.00");
assertThat(result.totalReceived()).isEqualByComparingTo("180.00");
assertThat(result.totalOutstanding()).isEqualByComparingTo("200.00");
assertThat(result.totalCustomers()).isEqualTo(2);
assertThat(result.records()).containsExactly(rows.get(0));
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class));
assertThat(sql.getValue()).contains("direction='RECEIVABLE'", "status<>'VOIDED'", "currency_code='CNY'",
"generated_date>=?", "generated_date<?", "GROUP BY tenant_id,customer_name_snapshot");
}
@Test
@SuppressWarnings("unchecked")
void failsClosedWhenPersistedTotalsDoNotReconcile() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99")));
assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc)
.query(YearMonth.of(2026, 7), null, 1, 20))
.isInstanceOf(FinanceEventException.class)
.hasMessageContaining("勾稽异常");
}
private static ReceivableStatisticsRow row(long id, String name, String total, String received, String outstanding) {
return new ReceivableStatisticsRow(id, name, "CNY", new BigDecimal(total),
new BigDecimal(received), new BigDecimal(outstanding));
}
}
@@ -3,9 +3,11 @@ package com.baiyue.finance;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.controller.ReconciliationStatementController;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -27,6 +29,7 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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;
@@ -80,7 +83,7 @@ class ReconciliationStatementBehaviorTest {
@Test
void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception {
JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账");
@@ -95,14 +98,14 @@ class ReconciliationStatementBehaviorTest {
@Test
void selectedEligibleCnySourceCreatesStableDraftAndLocksSource() throws Exception {
JdbcTemplate jdbc = createJdbc();
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
ReconciliationStatementService target = serviceWithNumber(jdbc);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "八月对账");
var result = target.create(71L, "create-key", request);
assertThat(result.statementId()).isEqualTo(501L);
assertThat(result.statementNo()).isEqualTo("REC-00000000000000000501");
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.createdByName()).isEqualTo("platform_admin");
assertThat(result.updatedByName()).isEqualTo("platform_admin");
@@ -181,9 +184,99 @@ class ReconciliationStatementBehaviorTest {
assertThat(result.status()).isEqualTo("ISSUED");
assertThat(result.version()).isEqualTo(3);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION'"), eq(501L), eq(9L));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
}
@Test
void customerConfirmationChecksScopeStateVersionAndWritesAudit() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
AtomicInteger billReads = new AtomicInteger();
when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
boolean confirmed = billReads.getAndIncrement() > 0;
return List.of(mapper.mapRow(billResultSet("ISSUED", confirmed ? 4 : 3,
confirmed ? "CONFIRMED" : "PENDING_CONFIRMATION", confirmed ? 71L : null), 0));
});
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
assertThat(result.confirmationStatus()).isEqualTo("CONFIRMED");
assertThat(result.confirmedBy()).isEqualTo(71L);
assertThat(result.version()).isEqualTo(4);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"),
eq(9L), eq("RECONCILIATION_STATEMENT"), eq(501L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), isNull(),
anyString(), anyString(), eq(71L), eq("操作人 71"), isNull(), isNull());
ArgumentCaptor<String> requestHash = ArgumentCaptor.forClass(String.class);
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_idempotency_record"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"), requestHash.capture());
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of(Map.of(
"request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 501L,
"updated_at", LocalDateTime.now())));
var replay = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
assertThat(replay.confirmationStatus()).isEqualTo("CONFIRMED");
verify(jdbc, org.mockito.Mockito.times(1)).update(
org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"),
eq(71L), eq("操作人 71"), eq(71L), eq("操作人 71"), eq(501L), eq(9L), eq(3));
}
@Test
void customerConfirmationHidesCrossCustomerAndRejectsInvalidState() throws Exception {
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
verify(crossCustomer, never()).queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), (Object[]) any());
JdbcTemplate draft = mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
when(draft.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet("DRAFT", 0, "NOT_REQUIRED", null), 0));
});
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(draft, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'"), (Object[]) any());
JdbcTemplate stale = mock(JdbcTemplate.class, invocation -> {
if ("update".equals(invocation.getMethod().getName()) && invocation.getMethod().getReturnType() == int.class) {
Object sql = invocation.getArguments()[0];
return sql instanceof String value && value.startsWith("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED'") ? 0 : 1;
}
return RETURNS_DEFAULTS.answer(invocation);
});
when(stale.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
return List.of(mapper.mapRow(billResultSet("ISSUED", 3, "PENDING_CONFIRMATION", null), 0));
});
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
}
@Test
void sameIdempotencyKeyWithDifferentActionBodyIsRejected() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -456,16 +549,24 @@ class ReconciliationStatementBehaviorTest {
}
private static ResultSet billResultSet(String status, int version) throws Exception {
return billResultSet(status, version, "ISSUED".equals(status) ? "PENDING_CONFIRMATION" : "NOT_REQUIRED", null);
}
private static ResultSet billResultSet(String status, int version, String confirmationStatus, Long confirmedBy) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(501L);
when(rs.getLong("tenant_id")).thenReturn(9L);
when(rs.getString("bill_no")).thenReturn("REC-00000000000000000501");
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
when(rs.getString("status")).thenReturn(status);
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
when(rs.getBigDecimal("received_amount")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("outstanding_amount")).thenReturn(new BigDecimal("300.00"));
when(rs.getInt("version")).thenReturn(version);
when(rs.getString("customer_confirmation_status")).thenReturn(confirmationStatus);
when(rs.getLong("customer_confirmed_by")).thenReturn(confirmedBy == null ? 0L : confirmedBy);
when(rs.wasNull()).thenReturn(confirmedBy == null);
when(rs.getString("customer_confirmed_by_name")).thenReturn(confirmedBy == null ? null : "操作人 71");
when(rs.getInt("credit_days")).thenReturn(30);
when(rs.getString("remark")).thenReturn("八月对账");
when(rs.getString("created_by_name")).thenReturn("platform_admin");
@@ -473,6 +574,7 @@ class ReconciliationStatementBehaviorTest {
Timestamp timestamp = Timestamp.valueOf("2026-08-11 10:00:00");
when(rs.getTimestamp("created_at")).thenReturn(timestamp);
when(rs.getTimestamp("updated_at")).thenReturn(timestamp);
when(rs.getTimestamp("customer_confirmed_at")).thenReturn(confirmedBy == null ? null : timestamp);
return rs;
}
@@ -486,4 +588,10 @@ class ReconciliationStatementBehaviorTest {
default -> 1;
};
}
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
}
}
@@ -0,0 +1,46 @@
package com.baiyue.finance.service;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void formatsShortDailySequenceForAllThreeDocuments() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
eq("REC:20260818"));
}
@Test
void rejectsUnsupportedTypeAndOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("REC"))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
}
}
@@ -8,6 +8,7 @@ import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -18,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.List;
import java.util.Map;
@@ -33,12 +35,13 @@ class FinanceExportProviderServiceTest {
@Mock FinanceQueryService queryService;
@Mock ReconciliationStatementService reconciliationService;
@Mock FinanceSettlementService settlementService;
@Mock ReceivableStatisticsService receivableStatisticsService;
private FinanceExportProviderService service;
@BeforeEach
void setUp() {
service = new FinanceExportProviderService(accountService, operationService, queryService,
reconciliationService, settlementService, new ObjectMapper(), "12345678901234567890123456789012");
reconciliationService, settlementService, receivableStatisticsService, new ObjectMapper(), "12345678901234567890123456789012");
service.initializeSigningKey();
}
@@ -46,6 +49,7 @@ class FinanceExportProviderServiceTest {
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3,
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"),
7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"));
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE",
"CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN,
@@ -53,14 +57,15 @@ class FinanceExportProviderServiceTest {
new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整");
ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A",
LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"),
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3, 7L, "创建人A",
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3,
"CONFIRMED", 17L, "客户确认人", LocalDateTime.parse("2026-08-05T10:00:00"), 7L, "创建人A",
LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"),
"八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"),
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED", true,
"AVAILABLE", true, null, null, "hash", 2, List.of());
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED",
"AVAILABLE", true, null, "hash", 2, List.of());
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
when(reconciliationService.detailForPlatform(501L)).thenReturn(detail);
when(operationService.getFinanceFeeSource(81L)).thenReturn(source);
@@ -107,6 +112,23 @@ class FinanceExportProviderServiceTest {
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null);
}
@Test
void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() {
when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of(
new ReceivableStatisticsRow(10001L, "客户A", "CNY", new BigDecimal("300"),
new BigDecimal("100"), new BigDecimal("200"))));
var result = service.exportBatch(FinanceExportProviderService.RECEIVABLE_STATISTICS,
request(Map.of("month", "2026-07", "customerId", "10001"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> assertThat(row)
.containsEntry("statisticsMonth", "2026-07")
.containsEntry("customerName", "客户A")
.containsEntry("currency", "CNY")
.doesNotContainKeys("fbaDirect", "fbaSpecialLine", "roundingDiscount"));
verify(receivableStatisticsService).exportRows(YearMonth.of(2026, 7), 10001L);
}
private ExportBatchRequest request(Map<String, Object> parameters, String cursor, String snapshot) {
return request(parameters, cursor, snapshot, 100);
}
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@@ -27,17 +28,19 @@ public class InternalFinanceFeeSourceController {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> inbound(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long inboundId, @PathVariable long feeId) {
@PathVariable long inboundId, @PathVariable long feeId,
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
authenticate(token);
return ApiResponse.success(service.inbound(inboundId, feeId));
return ApiResponse.success(service.inbound(inboundId, feeId, sourceVersion));
}
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> container(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long containerId, @PathVariable long feeId) {
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
authenticate(token);
return ApiResponse.success(service.container(containerId, feeId));
return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
}
private void authenticate(String token) {
@@ -20,9 +20,7 @@ public final class ContainerFeeDetailDtos {
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull Boolean settlementIncluded,
Long partnerId,
@Size(max = 200) String partnerName,
Long supplierId,
@Size(max = 500) String remark
) {
}
@@ -14,6 +14,7 @@ public final class FeeItemDtos {
@NotBlank @Size(max = 64) String category,
@NotBlank @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String tradeScope,
@Size(max = 50) @Pattern(regexp = "[A-Za-z0-9_-]{1,50}") String feeCode,
@Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String supplierCategory,
@NotBlank @Size(max = 128) String feeTypeName,
@NotNull Boolean pickupPayable,
@NotNull Boolean pickupReceivable,
@@ -22,12 +23,22 @@ public final class FeeItemDtos {
@NotNull Boolean allocationFee,
@Size(max = 500) String description
) {
public SaveRequest(String category, String tradeScope, String feeCode,
String feeTypeName, Boolean pickupPayable,
Boolean pickupReceivable, Boolean warehousePayable,
Boolean warehouseReceivable, Boolean allocationFee,
String description) {
this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable,
pickupReceivable, warehousePayable, warehouseReceivable,
allocationFee, description);
}
/** Backward-compatible constructor for callers predating the stable fee code. */
public SaveRequest(String category, String tradeScope, String feeTypeName,
Boolean pickupPayable, Boolean pickupReceivable,
Boolean warehousePayable, Boolean warehouseReceivable,
Boolean allocationFee, String description) {
this(category, tradeScope, null, feeTypeName, pickupPayable, pickupReceivable,
this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable,
warehousePayable, warehouseReceivable, allocationFee, description);
}
}
@@ -18,8 +18,9 @@ public record FinanceFeeSourceSnapshot(
String containerNo,
long customerId,
String customerName,
Long partnerId,
String partnerName,
Long supplierId,
String supplierName,
String supplierCategory,
Long feeItemId,
String feeItemCode,
String feeItemName,
@@ -29,7 +30,6 @@ public record FinanceFeeSourceSnapshot(
BigDecimal totalAmount,
String reviewStatus,
String publicationStatus,
boolean settlementIncluded,
LocalDateTime publishedAt,
BigDecimal chargeableQuantity,
String chargeUnit,
@@ -21,15 +21,13 @@ public final class InboundFeeDetailDtos {
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotNull Boolean settlementIncluded,
Long partnerId,
@Size(max = 200) String partnerName,
Long supplierId,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal unitPrice, String currency, BigDecimal amount,
String remark) {
this(feeItemId, direction, businessDate, unitPrice, currency, amount, false, null, null, remark);
this(feeItemId, direction, businessDate, unitPrice, currency, amount, null, remark);
}
}
@@ -63,7 +63,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
BigDecimal chargeableQuantity, String chargeUnit,
String feeTypeName, BigDecimal unitPrice, String currency, BigDecimal amount,
String reviewStatus, String publicationStatus,
LocalDateTime publishedAt, LocalDateTime inboundedAt, Boolean settlementIncluded
LocalDateTime publishedAt, LocalDateTime inboundedAt
) {
}
}
@@ -25,12 +25,12 @@ public class ContainerFeeDetailEntity {
private String feeItemCodeSnapshot;
private String feeItemNameSnapshot;
private String direction;
private Long partnerId;
private String partnerName;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
private Boolean settlementIncluded;
private String reviewStatus;
private String sourceStatus;
private Integer sourceVersion;
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 柜次费用审核发布时固化的不可变版本快照。 */
@Getter
@Setter
@TableName("fl_container_fee_source_snapshot")
public class ContainerFeeSourceSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long sourceFeeId;
private Integer sourceVersion;
private Long tenantId;
private Long loadingPlanId;
private Long containerBookingId;
private String containerNo;
private Long feeItemId;
private String feeItemCode;
private String feeItemName;
private String direction;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
private String reviewStatus;
private LocalDateTime reviewedAt;
private LocalDateTime createdAt;
}
@@ -18,6 +18,7 @@ public class FeeItemEntity extends BaseEntity {
private String category;
private String tradeScope;
private String feeCode;
private String supplierCategory;
private String feeTypeName;
private Boolean pickupPayable;
private Boolean pickupReceivable;
@@ -28,4 +29,5 @@ public class FeeItemEntity extends BaseEntity {
private String status;
private String createdByName;
private String updatedByName;
}
@@ -27,8 +27,9 @@ public class InboundFeeDetailEntity extends BaseEntity {
private String sourceFeeNo;
private String inboundCode;
private String direction;
private Long partnerId;
private String partnerName;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private LocalDate businessDate;
private Long customerId;
private String customerName;
@@ -62,5 +63,4 @@ public class InboundFeeDetailEntity extends BaseEntity {
private String publishedByName;
private LocalDateTime publishedAt;
private LocalDateTime inboundedAt;
private Boolean settlementIncluded;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContainerFeeSourceSnapshotMapper extends BaseMapper<ContainerFeeSourceSnapshotEntity> {
}
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -42,6 +43,7 @@ public class OceanBookingService {
@Resource private ObjectMapper objectMapper;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private WarehouseMasterDataClient warehouses;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -80,7 +82,7 @@ public class OceanBookingService {
ResolvedDraft resolved = resolveDraft(request.data());
OceanBookingEntity value = new OceanBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("OB" + value.getId());
value.setBookingNo(documentNumbers.next("OB"));
value.setStatus(OceanBookingStatus.DRAFT.name());
copy(value, request.data(), resolved);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
@@ -42,6 +42,22 @@ public class ContainerFeeDetailRepository {
mapper.insert(value);
}
public int updateSupplierSnapshot(long tenantId, long loadingPlanId, long feeId,
Long supplierId, String supplierName,
String supplierCategory, long operatorId) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getReviewStatus, "PENDING")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.set(ContainerFeeDetailEntity::getSupplierId, supplierId)
.set(ContainerFeeDetailEntity::getSupplierName, supplierName)
.set(ContainerFeeDetailEntity::getSupplierCategory, supplierCategory)
.set(ContainerFeeDetailEntity::getUpdatedBy, operatorId)
.set(ContainerFeeDetailEntity::getUpdatedAt, LocalDateTime.now()));
}
public int review(long tenantId, long loadingPlanId, long feeId, String status,
long reviewerId, String reviewerName, String reason) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
@RequiredArgsConstructor
public class ContainerFeeSourceSnapshotRepository {
private final ContainerFeeSourceSnapshotMapper mapper;
public void saveApproved(ContainerFeeDetailEntity source) {
ContainerFeeSourceSnapshotEntity value = new ContainerFeeSourceSnapshotEntity();
value.setSourceFeeId(source.getId());
value.setSourceVersion(source.getSourceVersion());
value.setTenantId(source.getTenantId());
value.setLoadingPlanId(source.getLoadingPlanId());
value.setContainerBookingId(source.getContainerBookingId());
value.setContainerNo(source.getContainerNo());
value.setFeeItemId(source.getFeeItemId());
value.setFeeItemCode(source.getFeeItemCodeSnapshot());
value.setFeeItemName(source.getFeeItemNameSnapshot());
value.setDirection(source.getDirection());
value.setSupplierId(source.getSupplierId());
value.setSupplierName(source.getSupplierName());
value.setSupplierCategory(source.getSupplierCategory());
value.setBusinessDate(source.getBusinessDate());
value.setAmount(source.getAmount());
value.setCurrency(source.getCurrency());
value.setReviewStatus(source.getReviewStatus());
value.setReviewedAt(source.getReviewedAt());
value.setCreatedAt(LocalDateTime.now());
mapper.insert(value);
}
public ContainerFeeSourceSnapshotEntity find(long loadingPlanId, long sourceFeeId,
int sourceVersion) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeSourceSnapshotEntity>()
.eq(ContainerFeeSourceSnapshotEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeSourceSnapshotEntity::getSourceFeeId, sourceFeeId)
.eq(ContainerFeeSourceSnapshotEntity::getSourceVersion, sourceVersion));
}
}
@@ -121,6 +121,15 @@ public class FeeItemRepository {
.eq(InboundFeeDetailEntity::getId, detailId));
}
public InboundFeeDetailEntity findSourceVersion(long inboundId, long sourceFeeId,
int sourceVersion) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getSourceFeeVersion, sourceVersion)
.and(scope -> scope.eq(InboundFeeDetailEntity::getId, sourceFeeId)
.or().eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId)));
}
public InboundFeeDetailEntity findBySourceFee(String sourceType, long sourceFeeId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getSourceType, sourceType)
@@ -166,7 +175,6 @@ public class FeeItemRepository {
.in(InboundFeeDetailEntity::getPublicationStatus, List.of("DRAFT", "PUBLISHED"))
.ne(InboundFeeDetailEntity::getSourceFeeId, retainedSourceFeeId)
.set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
.set(InboundFeeDetailEntity::getSettlementIncluded, false)
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
.set(InboundFeeDetailEntity::getUpdatedAt, now));
}
@@ -186,7 +194,6 @@ public class FeeItemRepository {
.set(InboundFeeDetailEntity::getPublishedBy, operatorId)
.set(InboundFeeDetailEntity::getPublishedByName, operatorName)
.set(InboundFeeDetailEntity::getPublishedAt, now)
.set(InboundFeeDetailEntity::getSettlementIncluded, true)
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
.set(InboundFeeDetailEntity::getUpdatedAt, now));
}
@@ -9,12 +9,12 @@ import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.RoundingMode;
import java.time.LocalDateTime;
@@ -27,6 +27,8 @@ public class ContainerFeeDetailService {
private final SmartLoadingRepository loading;
private final FeeItemRepository feeItems;
private final FinanceFeeSourceOutboxService financeOutbox;
private final WarehouseSupplierSnapshotService suppliers;
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
requireOwnedPlan(tenantId, loadingPlanId);
@@ -56,18 +58,19 @@ public class ContainerFeeDetailService {
@Transactional
public void approve(long tenantId, long loadingPlanId, long feeId,
long reviewerId, String reviewerName) {
required(tenantId, loadingPlanId, feeId);
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核柜次费用可以审核通过");
}
ContainerFeeDetailEntity approved = required(tenantId, loadingPlanId, feeId);
if (Boolean.TRUE.equals(approved.getSettlementIncluded())) {
String eventType = approved.getSourceVersion() > 1
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
approved.getSourceVersion(), LocalDateTime.now());
}
sourceSnapshots.saveApproved(approved);
String eventType = approved.getSourceVersion() > 1
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
approved.getSourceVersion(), LocalDateTime.now());
}
@Transactional
@@ -111,10 +114,8 @@ public class ContainerFeeDetailService {
if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以作废");
}
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
}
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
}
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
@@ -144,28 +145,49 @@ public class ContainerFeeDetailService {
return value;
}
private static void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
SaveRequest request, long operatorId) {
if ("PAYABLE".equals(request.direction())
&& (request.partnerId() == null || request.partnerId() <= 0
|| !StringUtils.hasText(request.partnerName()))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效往来单位");
}
private void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
SaveRequest request, long operatorId) {
value.setFeeItemId(item.getId());
value.setFeeItemCodeSnapshot(item.getFeeCode());
value.setFeeItemNameSnapshot(item.getFeeTypeName());
value.setDirection(request.direction());
value.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null);
value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName().trim() : null);
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
value.setBusinessDate(request.businessDate());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
value.setUpdatedAt(LocalDateTime.now());
}
private void applySupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
String direction, Long supplierId) {
if (!"PAYABLE".equals(direction)) {
value.setSupplierId(null);
value.setSupplierName(null);
value.setSupplierCategory(null);
return;
}
if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商");
}
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category());
}
private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
long operatorId) {
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
if (repository.updateSupplierSnapshot(value.getTenantId(), value.getLoadingPlanId(),
value.getId(), value.getSupplierId(), value.getSupplierName(),
value.getSupplierCategory(), operatorId) != 1) {
throw conflict("柜次费用供应商快照已变化,请刷新后重试");
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
@@ -47,6 +47,7 @@ public class ContainerService {
@Resource private PriceBookQueryRepository priceBooks;
@Resource private OceanBookingRepository oceanBookings;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -66,7 +67,7 @@ public class ContainerService {
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId()); value.setCabinetNo("CAB-" + value.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
@@ -102,10 +102,19 @@ public class FeeItemService {
}
private static void copy(FeeItemEntity value, SaveRequest request) {
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|| Boolean.TRUE.equals(request.warehousePayable());
if (payable && (request.supplierCategory() == null
|| request.supplierCategory().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"启用应付方向时必须配置供应商类别");
}
value.setCategory(request.category().trim());
value.setTradeScope(request.tradeScope());
value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank()
? value.getFeeCode() : request.feeCode().trim().toUpperCase());
value.setSupplierCategory(payable
? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null);
value.setFeeTypeName(request.feeTypeName().trim());
value.setPickupPayable(request.pickupPayable());
value.setPickupReceivable(request.pickupReceivable());
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
import java.util.Set;
/** 费用项目显式供应商类别校验;未配置的应付费用失败关闭。 */
public final class FeeItemSupplierCategory {
private static final Set<String> ALLOWED = Set.of(
"BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
private FeeItemSupplierCategory() {
}
public static String normalize(String supplierCategory) {
if (supplierCategory == null || supplierCategory.isBlank()) return null;
String normalized = supplierCategory.trim().toUpperCase(Locale.ROOT);
if (!ALLOWED.contains(normalized)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效");
}
return normalized;
}
public static String requirePayable(String supplierCategory) {
String category = normalize(supplierCategory);
if (category == null) {
throw new BizException(ResultCode.CONFLICT,
"费用项目未配置供应商类别,不能用于应付费用");
}
return category;
}
}
@@ -3,11 +3,11 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -21,11 +21,10 @@ import java.util.HexFormat;
@RequiredArgsConstructor
public class FinanceFeeSourceSnapshotService {
private final FeeItemRepository inboundFees;
private final ContainerFeeDetailRepository containerFees;
private final ContainerFeeSourceSnapshotRepository containerSnapshots;
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId) {
InboundFeeDetailEntity detail = inboundFees.findDetail(inboundId, feeId);
if (detail == null) detail = inboundFees.findBySourceFee("FBA_PRICING", feeId);
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId, int sourceVersion) {
InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId, feeId, sourceVersion);
if (detail == null || !Long.valueOf(inboundId).equals(detail.getInboundId())) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源不存在");
}
@@ -34,43 +33,44 @@ public class FinanceFeeSourceSnapshotService {
if (inbound == null || item == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源关联快照不完整");
}
long sourceFeeId = "FBA_PRICING".equals(detail.getSourceType())
? detail.getSourceFeeId() : detail.getId();
long sourceFeeId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId();
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
String canonical = String.join("|", "INBOUND_FEE", String.valueOf(sourceFeeId),
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(inboundId),
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
nullSafe(detail.getDirection()), detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()), String.valueOf(detail.getSettlementIncluded()));
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
detail.getTenantId(), inbound.getCustomerName(), detail.getPartnerId(), detail.getPartnerName(), item.getId(), item.getFeeCode(),
detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), item.getId(), item.getFeeCode(),
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
detail.getPublicationStatus(), Boolean.TRUE.equals(detail.getSettlementIncluded()),
detail.getPublicationStatus(),
detail.getPublishedAt(), detail.getChargeableQuantity(), detail.getChargeUnit(),
detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(), sha256(canonical), 1);
}
public FinanceFeeSourceSnapshot container(long containerId, long feeId) {
ContainerFeeDetailEntity detail = containerFees.find(containerId, feeId);
public FinanceFeeSourceSnapshot container(long containerId, long feeId, int sourceVersion) {
ContainerFeeSourceSnapshotEntity detail = containerSnapshots.find(containerId, feeId, sourceVersion);
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用来源不存在");
int version = detail.getSourceVersion();
String publicationStatus = "VOIDED".equals(detail.getSourceStatus()) ? "VOIDED"
: "APPROVED".equals(detail.getReviewStatus()) ? "PUBLISHED" : "DRAFT";
String canonical = String.join("|", "CONTAINER_FEE", String.valueOf(feeId),
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerId),
nullSafe(detail.getFeeItemCodeSnapshot()), nullSafe(detail.getDirection()),
nullSafe(detail.getPartnerId()), nullSafe(detail.getPartnerName()),
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
nullSafe(detail.getSupplierCategory()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
String.valueOf(detail.getSettlementIncluded()));
nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getPartnerId(),
detail.getPartnerName(), detail.getFeeItemId(),
detail.getFeeItemCodeSnapshot(), detail.getFeeItemNameSnapshot(), detail.getDirection(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getFeeItemId(),
detail.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(),
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
publicationStatus, Boolean.TRUE.equals(detail.getSettlementIncluded()),
"PUBLISHED",
detail.getReviewedAt(), null, null, null, null, sha256(canonical), 1);
}
@@ -0,0 +1,55 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
@Component
public class FirstlegDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "CAB", "LP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private final Clock clock;
public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
}
FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
jdbc.update(
"INSERT INTO fl_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
}
}
@@ -29,6 +29,8 @@ public class InboundFeeDetailService {
private FbaPricingRepository pricingRepository;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private WarehouseSupplierSnapshotService suppliers;
@Transactional
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
@@ -130,7 +132,6 @@ public class InboundFeeDetailService {
detail.setPublishedByName(operatorName);
detail.setPublishedAt(now);
detail.setInboundedAt(inbound.getInboundedAt());
detail.setSettlementIncluded(true);
detail.setCreatedBy(operatorId);
detail.setUpdatedBy(operatorId);
try {
@@ -199,7 +200,7 @@ public class InboundFeeDetailService {
detail.setRemark("来源费用单:" + fee.getFeeNo());
detail.setReviewStatus(published ? "APPROVED" : "PENDING");
detail.setPublicationStatus(published ? "PUBLISHED" : "DRAFT");
detail.setSettlementIncluded(published); detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId);
detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId);
if (published) {
detail.setReviewerId(operatorId); detail.setReviewerName(operatorName); detail.setReviewedAt(now);
detail.setPublishedBy(operatorId); detail.setPublishedByName(operatorName); detail.setPublishedAt(now);
@@ -252,7 +253,9 @@ public class InboundFeeDetailService {
@Transactional
public void approve(long tenantId, long inboundId, long detailId,
long reviewerId, String reviewerName) {
requiredManual(tenantId, inboundId, detailId);
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核费用明细可以审核通过");
@@ -301,10 +304,8 @@ public class InboundFeeDetailService {
if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核费用明细可以作废");
}
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
current.setPublicationStatus("VOIDED");
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
}
current.setPublicationStatus("VOIDED");
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
}
@Transactional
@@ -373,14 +374,13 @@ public class InboundFeeDetailService {
return value;
}
private static void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
ensureWarehouseApplicable(item, request.direction());
value.setFeeItemId(item.getId());
value.setInboundCode(inbound.getInboundCode());
value.setDirection(request.direction());
value.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null);
value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName() : null);
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
value.setBusinessDate(request.businessDate());
value.setFeeCategorySnapshot(item.getCategory());
value.setTradeScopeSnapshot(item.getTradeScope());
@@ -399,18 +399,43 @@ public class InboundFeeDetailService {
value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
}
private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
String direction, Long supplierId) {
if (!"PAYABLE".equals(direction)) {
value.setSupplierId(null);
value.setSupplierName(null);
value.setSupplierCategory(null);
return;
}
if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付入库费用必须选择有效供应商");
}
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category());
}
private void refreshSupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
long operatorId) {
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
value.setUpdatedBy(operatorId);
if (repository.updateDetail(value) != 1) {
throw conflict("费用明细供应商快照已变化,请刷新后重试");
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private void publishFinanceSource(InboundFeeDetailEntity detail, String status,
LocalDateTime occurredAt) {
if (!Boolean.TRUE.equals(detail.getSettlementIncluded())) return;
if ("FBA_PRICING".equals(detail.getSourceType())) return;
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED
@@ -149,7 +149,7 @@ public class MerchantInboundBillingService {
detail.getChargeableVolumeCbm(), detail.getChargeableQuantity(), detail.getChargeUnit(),
detail.getFeeTypeNameSnapshot(), detail.getUnitPrice(), detail.getCurrency(), detail.getAmount(),
detail.getReviewStatus(), detail.getPublicationStatus(), detail.getPublishedAt(),
detail.getInboundedAt(), detail.getSettlementIncluded());
detail.getInboundedAt());
}
public record ImageDownload(byte[] content, String contentType) {
@@ -39,6 +39,7 @@ public class SmartLoadingService {
@Resource private LoadingPickService loadingPickService;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
@@ -77,7 +78,7 @@ public class SmartLoadingService {
LoadingPlanEntity existing = repository.planByBooking(bookingId);
if (existing != null) return draftView(existing, booking);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20));
plan.setPlanNo(documentNumbers.next("LP"));
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName); plan.setVersion(0);
@@ -40,6 +40,7 @@ public class TruckAppointmentService {
@Resource TruckAppointmentFileClient files;
@Resource WarehouseSupplierSnapshotService suppliers;
@Resource IamCustomerSnapshotService customers;
@Resource FirstlegDocumentNumberGenerator documentNumbers;
@Resource ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") String internalToken;
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
@@ -47,7 +48,7 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@@ -26,6 +26,14 @@ public class WarehouseSupplierSnapshotService {
return resolve(supplierId, suppliedName, "CUSTOMS");
}
/** 费用来源只提交供应商 ID,名称与类别均以 Warehouse 返回快照为准。 */
public WarehouseSupplierClient.Snapshot fee(Long supplierId, String category) {
if (!StringUtils.hasText(category)) {
throw new BizException(ResultCode.CONFLICT, "费用项目未配置供应商类别");
}
return resolve(supplierId, null, category);
}
private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName,
String category) {
if (supplierId == null || supplierId <= 0) {
@@ -71,12 +71,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照',
fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
settlement_included TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与财务结算来源池',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回',
source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废',
source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本',
@@ -91,22 +91,46 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status),
KEY idx_container_fee_settlement (tenant_id, review_status, settlement_included, source_status),
KEY idx_container_fee_review (tenant_id, review_status, source_status),
CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance';
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id',
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id',
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
id BIGINT UNSIGNED NOT NULL,
source_fee_id BIGINT UNSIGNED NOT NULL,
source_version INT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
loading_plan_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
container_no VARCHAR(64) NULL,
fee_item_id BIGINT UNSIGNED NOT NULL,
fee_item_code VARCHAR(50) NOT NULL,
fee_item_name VARCHAR(128) NOT NULL,
direction VARCHAR(16) NOT NULL,
supplier_id BIGINT UNSIGNED NULL,
supplier_name VARCHAR(200) NULL,
supplier_category VARCHAR(16) NULL,
business_date DATE NOT NULL,
amount DECIMAL(18,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'CNY',
review_status VARCHAR(16) NOT NULL,
reviewed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version),
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -125,13 +149,13 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整';
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整';
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
END IF;
END$$
DELIMITER ;
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
category VARCHAR(64) NOT NULL COMMENT '费用分类',
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
@@ -36,8 +37,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
@@ -22,6 +22,8 @@ DELIMITER ;
CALL phase2_publish_add_column('fl_fee_item', 'fee_code',
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
CALL phase2_publish_add_column('fl_fee_item', 'supplier_category',
"VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭' AFTER fee_code");
ALTER TABLE fl_fee_item
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id',
COMMENT = '平台公共费用项目主数据';
@@ -431,9 +433,6 @@ CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_at',
"DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'inbounded_at',
"DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'settlement_included',
"TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与结算' AFTER inbounded_at");
DROP PROCEDURE IF EXISTS phase2_publish_add_column;
DROP PROCEDURE IF EXISTS phase2_publish_add_index;
@@ -26,6 +26,13 @@ DELIMITER ;
CALL phase3_add_allocation_picked_count();
DROP PROCEDURE phase3_add_allocation_picked_count;
CREATE TABLE IF NOT EXISTS fl_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818',
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
@@ -295,7 +295,7 @@ components:
nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT}
InboundFeeDetailSaveRequest:
type: object
required: [feeItemId, direction, businessDate, unitPrice, currency, amount, settlementIncluded]
required: [feeItemId, direction, businessDate, unitPrice, currency, amount]
properties:
feeItemId: {type: string, pattern: '^[1-9][0-9]*$'}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
@@ -303,9 +303,9 @@ components:
unitPrice: {type: number, minimum: 0}
currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number, minimum: 0}
settlementIncluded: {type: boolean}
partnerId: {type: string, nullable: true}
partnerName: {type: string, maxLength: 200, nullable: true}
supplierId: {type: string, nullable: true}
supplierName: {type: string, maxLength: 200, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
remark: {type: string, maxLength: 500, nullable: true}
InboundFeeDetailVersionRequest:
type: object
@@ -318,7 +318,7 @@ components:
InboundFeeDetailResource:
type: object
description: 入仓费用展示记录。sourceType=FBA_PRICING 时是只读派生;sourceType=MANUAL 时按 sourceFeeId/sourceFeeVersion 独立版本化。
required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus, settlementIncluded]
required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -330,8 +330,9 @@ components:
sourceFeeNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
partnerId: {type: string, nullable: true}
partnerName: {type: string, nullable: true}
supplierId: {type: string, nullable: true}
supplierName: {type: string, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
businessDate: {type: string, format: date}
customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true}
@@ -365,7 +366,6 @@ components:
publishedByName: {type: string, nullable: true}
publishedAt: {type: string, format: date-time, nullable: true}
inboundedAt: {type: string, format: date-time, nullable: true}
settlementIncluded: {type: boolean}
createdBy: {type: string, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedBy: {type: string, nullable: true}
@@ -100,6 +100,7 @@ components:
category: {type: string, maxLength: 64}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: pickupPayable或warehousePayable为true时必填}
feeTypeName: {type: string, maxLength: 128}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
@@ -115,6 +116,7 @@ components:
category: {type: string}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
feeTypeName: {type: string}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
@@ -838,7 +838,7 @@ paths:
summary: 调整已审核柜次费用的日期和金额
description: >-
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
PENDING;费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照。身份或结算字段需要变化时,
PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时,
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
parameters:
@@ -1446,10 +1446,10 @@ components:
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
planNo: {type: string, nullable: true, description: 装柜清单号;未创建计划时为空}
bookingNo: {type: string, maxLength: 40}
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 平台稳定柜次号}
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
@@ -1580,7 +1580,7 @@ components:
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
properties:
id: {type: string}
planNo: {type: string}
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
bookingId: {type: string}
containerNo: {type: string, nullable: true}
@@ -1692,7 +1692,6 @@ components:
publicationStatus: {type: string, enum: [PUBLISHED]}
publishedAt: {type: string, format: date-time}
inboundedAt: {type: string, format: date-time, nullable: true}
settlementIncluded: {type: boolean, example: true}
InboundOrderPageEnvelope:
type: object
properties:
@@ -294,9 +294,9 @@ components:
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
preloadNo: {type: string, description: 预装单号}
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
containerNo: {type: string, nullable: true, description: 集装箱号}
cabinetNo: {type: string, nullable: true, description: 稳定柜次号;不得与真实集装箱号混用}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号;不得与真实集装箱号混用}
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
@@ -351,11 +351,11 @@ components:
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
loadingListNo: {type: string, description: 装柜清单号}
loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
containerNo: {type: string, nullable: true, description: 集装箱号}
cabinetNo: {type: string, nullable: true, description: 稳定柜次号}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号}
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
@@ -94,4 +94,15 @@ components:
channelName: {type: string}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
Detail: {type: object, required: [oceanBooking, parties, containerRequirements, containers, histories], properties: {oceanBooking: {type: object}, parties: {type: array}, containerRequirements: {type: array}, containers: {type: array, description: Existing containers allocated after port pickup linking}, histories: {type: array}}}
Detail:
type: object
required: [oceanBooking, parties, containerRequirements, containers, histories]
properties:
oceanBooking:
type: object
properties:
bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号}
parties: {type: array}
containerRequirements: {type: array}
containers: {type: array, description: Existing containers allocated after port pickup linking}
histories: {type: array}
@@ -55,7 +55,7 @@ components:
exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
@@ -29,7 +29,7 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200': {description: Created appointment}
'200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}}
/api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
@@ -182,13 +182,14 @@ components:
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
DetailStableReferences:
type: object
required: [customerId, customerName, carrierId, carrierName, attachmentCount]
required: [appointmentNo, customerId, customerName, carrierId, carrierName, attachmentCount]
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Fulfillment associations remain null for an independently created appointment.
properties:
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingNo: {type: string, nullable: true}
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
containerBookingId: {type: integer, format: int64, nullable: true}
cabinetNo: {type: string, nullable: true}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}
containerNo: {type: string, nullable: true}
loadingPlanId: {type: integer, format: int64, nullable: true}
warehouseId: {type: integer, format: int64, nullable: true}
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照")
.contains("费用方向、费用项目、往来单位、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,6 +36,7 @@ class OceanBookingServiceTest {
@Mock private PriceBookQueryRepository priceBooks;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@@ -42,6 +44,7 @@ class OceanBookingServiceTest {
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules();
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, objectMapper);
@@ -60,6 +63,7 @@ class OceanBookingServiceTest {
new CreateRequest(0, partialDraft(null)));
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(result.oceanBooking().getBookingNo()).isEqualTo("OB-260818-0001");
assertThat(inserted.get().getCustomerId()).isNull();
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
verify(repository, never()).insertContainer(any());
@@ -22,6 +22,6 @@ class ContainerFeeDetailRepositoryTest {
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
"getSettlementIncluded", "getRemark");
"getRemark");
}
}
@@ -32,7 +32,7 @@ class FinanceFeeSourceMigrationContractTest {
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"settlement_included", "source_version", "partner_id", "partner_name",
"source_version", "partner_id", "partner_name",
"firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整",
@@ -31,6 +31,7 @@ class SmartLoadingSqlContractTest {
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_document_sequence")
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
@@ -59,7 +59,6 @@ class ContainerFeeDetailServiceTest {
approved.setTenantId(10001L);
approved.setLoadingPlanId(41L);
approved.setSourceVersion(1);
approved.setSettlementIncluded(true);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
@@ -92,7 +91,6 @@ class ContainerFeeDetailServiceTest {
assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getPartnerId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getSettlementIncluded()).isTrue();
assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong());
}
@@ -170,7 +168,7 @@ class ContainerFeeDetailServiceTest {
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, null, null, "整柜拖车费");
null, null, "整柜拖车费");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位");
@@ -184,7 +182,7 @@ class ContainerFeeDetailServiceTest {
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, 71L, "不应保留", "整柜操作费");
71L, "不应保留", "整柜操作费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
@@ -225,7 +223,6 @@ class ContainerFeeDetailServiceTest {
value.setPartnerId(71L);
value.setPartnerName("现有往来单位");
value.setCurrency("CNY");
value.setSettlementIncluded(true);
value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11));
value.setAmount(new BigDecimal("300.00"));
@@ -239,9 +236,9 @@ class ContainerFeeDetailServiceTest {
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
settlementIncluded, 71L, " 现有往来单位 ", "整柜拖车费");
71L, " 现有往来单位 ", "整柜拖车费");
}
}
@@ -54,11 +54,14 @@ class ContainerServiceTest {
@MockBean private PriceBookQueryRepository priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
@@ -77,8 +80,8 @@ class ContainerServiceTest {
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).startsWith("CAB-");
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelName()).isEqualTo("美西海运");
@@ -220,7 +220,6 @@ class FeeManagementServiceTest {
assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198");
assertThat(saved.getReviewStatus()).isEqualTo("APPROVED");
assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED");
assertThat(saved.getSettlementIncluded()).isTrue();
}
@Test
@@ -324,16 +323,24 @@ class FeeManagementServiceTest {
@Test
@DisplayName("审核只更新待审核明细")
void approvesPendingDetail() {
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(new InboundFeeDetailEntity());
InboundFeeDetailEntity pending = new InboundFeeDetailEntity();
pending.setId(31L);
pending.setTenantId(10001L);
pending.setInboundId(21L);
pending.setSourceType("MANUAL");
pending.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
@DisplayName("审核通过的人工费用自动写入Finance事件")
void publishesApprovedSettlementFee() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
@@ -342,7 +349,6 @@ class FeeManagementServiceTest {
approved.setFeeItemId(11L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
@@ -409,7 +415,6 @@ class FeeManagementServiceTest {
current.setFeeItemId(11L);
current.setSourceType("MANUAL");
current.setSourceFeeVersion(1);
current.setSettlementIncluded(true);
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
voided.setId(31L);
voided.setTenantId(10001L);
@@ -417,7 +422,6 @@ class FeeManagementServiceTest {
voided.setFeeItemId(11L);
voided.setSourceType("MANUAL");
voided.setSourceFeeVersion(1);
voided.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
@@ -3,10 +3,10 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
@@ -19,7 +19,7 @@ import static org.mockito.Mockito.when;
class FinanceFeeSourceSnapshotServiceTest {
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
private final ContainerFeeSourceSnapshotRepository containerFees = mock(ContainerFeeSourceSnapshotRepository.class);
private final FinanceFeeSourceSnapshotService service =
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
@@ -37,8 +37,7 @@ class FinanceFeeSourceSnapshotServiceTest {
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setPublicationStatus("PUBLISHED");
detail.setSettlementIncluded(true);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 2)).thenReturn(detail);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setOrderId(81L);
@@ -50,53 +49,53 @@ class FinanceFeeSourceSnapshotServiceTest {
item.setFeeCode("STORAGE");
when(inboundFees.findItem(11L)).thenReturn(item);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L, 2);
assertThat(snapshot.customerId()).isEqualTo(10001L);
assertThat(snapshot.orderId()).isEqualTo(81L);
assertThat(snapshot.feeVersion()).isEqualTo(2);
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
detail.setPublicationStatus("VOIDED");
assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash());
assertThat(service.inbound(21L, 31L, 2).sourceHash()).isEqualTo(snapshot.sourceHash());
}
@Test
void rejectsFeeThatDoesNotBelongToInboundPath() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setInboundId(22L);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 1)).thenReturn(detail);
assertThatThrownBy(() -> service.inbound(21L, 31L))
assertThatThrownBy(() -> service.inbound(21L, 31L, 1))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
@Test
void containerSnapshotPublishesPayableCounterpartyIdentity() {
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
detail.setId(51L);
ContainerFeeSourceSnapshotEntity detail = new ContainerFeeSourceSnapshotEntity();
detail.setSourceFeeId(51L);
detail.setTenantId(10001L);
detail.setLoadingPlanId(41L);
detail.setContainerBookingId(61L);
detail.setContainerNo("CONT-41");
detail.setFeeItemId(11L);
detail.setFeeItemCodeSnapshot("TRUCKING");
detail.setFeeItemNameSnapshot("拖车费");
detail.setFeeItemCode("TRUCKING");
detail.setFeeItemName("拖车费");
detail.setDirection("PAYABLE");
detail.setPartnerId(71L);
detail.setPartnerName("现有往来单位");
detail.setSupplierId(71L);
detail.setSupplierName("现有供应商");
detail.setSupplierCategory("TRUCKING");
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
detail.setAmount(new BigDecimal("300.00"));
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setSourceStatus("ACTIVE");
detail.setSourceVersion(2);
detail.setSettlementIncluded(true);
when(containerFees.find(41L, 51L)).thenReturn(detail);
when(containerFees.find(41L, 51L, 2)).thenReturn(detail);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L, 2);
assertThat(snapshot.partnerId()).isEqualTo(71L);
assertThat(snapshot.partnerName()).isEqualTo("现有往来单位");
assertThat(snapshot.supplierId()).isEqualTo(71L);
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FirstlegDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void formatsAllOperationalDocumentPrefixes() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(1L, 1L, 1L, 1L, 1L);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001");
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
eq("OB:20260818"));
}
@Test
void rejectsUnsupportedTypeAndDailyOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class);
}
}
@@ -63,6 +63,7 @@ class SmartLoadingServiceTest {
@MockBean private LoadingPickService loadingPickService;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService service;
@TestConfiguration
@@ -73,6 +74,7 @@ class SmartLoadingServiceTest {
@BeforeEach
void setUp() {
when(documentNumbers.next("LP")).thenReturn("LP-260818-0001");
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
@@ -174,6 +176,22 @@ class SmartLoadingServiceTest {
verify(repository, never()).insert(any(LoadingPlanEntity.class));
}
@Test
void createsPlanWithShortStableNumber() {
when(repository.booking(99L)).thenReturn(booking());
when(repository.planByBooking(99L)).thenReturn(null);
doAnswer(invocation -> {
LoadingPlanEntity value = invocation.getArgument(0);
value.setId(1L);
return 1;
}).when(repository).insert(any(LoadingPlanEntity.class));
when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u");
assertThat(result.planNo()).isEqualTo("LP-260818-0001");
}
@Test
void rejectsExcessBoxes() {
LoadingPlanEntity plan = plan("EDITING", 0);
@@ -47,11 +47,13 @@ class TruckAppointmentServiceTest {
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Mock FirstlegDocumentNumberGenerator documentNumbers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
service.internalToken = "test-internal-token";
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
"Canonical Customer", "ACTIVE", null, null));
@@ -107,6 +109,7 @@ class TruckAppointmentServiceTest {
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}