优化
This commit is contained in:
@@ -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:}
|
||||
|
||||
@@ -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和数据隔离键',
|
||||
|
||||
@@ -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 = "客户只读查询结算应收")
|
||||
|
||||
+37
-10
@@ -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) {}
|
||||
|
||||
+42
@@ -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", "柜子归属不一致");
|
||||
|
||||
+69
-21
@@ -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) {}
|
||||
}
|
||||
+68
-8
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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");
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -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);
|
||||
}
|
||||
|
||||
+7
-4
@@ -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;
|
||||
|
||||
+39
@@ -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;
|
||||
}
|
||||
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+3
-1
@@ -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);
|
||||
|
||||
+16
@@ -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>()
|
||||
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
-23
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+55
@@ -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
|
||||
|
||||
+1
-1
@@ -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);}
|
||||
|
||||
+8
@@ -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}
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
|
||||
.contains("ContainerFeeAdjustRequest:")
|
||||
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
||||
.contains("additionalProperties: false")
|
||||
.contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照")
|
||||
.contains("费用方向、费用项目、往来单位、币种和备注保持原快照")
|
||||
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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());
|
||||
|
||||
+1
-1
@@ -22,6 +22,6 @@ class ContainerFeeDetailRepositoryTest {
|
||||
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
|
||||
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
|
||||
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
|
||||
"getSettlementIncluded", "getRemark");
|
||||
"getRemark");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+4
-7
@@ -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);
|
||||
|
||||
|
||||
+20
-21
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user