From ff1d330c2dfaf84badc53834df4f3e7274d52059 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 19 Aug 2026 09:14:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/file-service.yml | 9 ++ config/nacos/local/file-service.yml | 9 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 14 +++ file/src/main/resources/application.yml | 9 ++ .../config/ExportDefinitionRegistryTest.java | 7 ++ .../client/FirstlegFeeSourceClient.java | 12 +- .../controller/ClientFinanceController.java | 19 ++- .../FinancePhase1OperationController.java | 47 +++++-- .../controller/PlatformFinanceController.java | 17 --- .../ReceivableStatisticsController.java | 42 +++++++ .../baiyue/finance/dto/FinancePhase1Dtos.java | 9 +- .../baiyue/finance/dto/FinanceReadModels.java | 8 ++ .../finance/dto/FinanceSettlementDtos.java | 2 +- .../finance/dto/ReceivableStatisticsDtos.java | 19 +++ .../dto/ReconciliationStatementDtos.java | 8 ++ .../FinanceDocumentNumberGenerator.java | 53 ++++++++ .../service/FinanceExportProviderService.java | 51 ++++++-- .../service/FinanceFeeSourceEventService.java | 20 ++- .../FinancePhase1OperationService.java | 90 ++++++++++---- .../finance/service/FinanceQueryService.java | 17 ++- .../finance/service/FinanceService.java | 101 +-------------- .../service/FinanceSettlementService.java | 55 +++++---- .../service/FinanceSupplierValidator.java | 15 --- .../service/ReceivableStatisticsService.java | 103 ++++++++++++++++ .../ReconciliationStatementService.java | 76 ++++++++++-- .../db/fee-source-event-migration.sql | 13 +- finance/src/main/resources/db/finance.sql | 13 +- .../CustomerAccountSummaryBehaviorTest.java | 2 +- .../baiyue/finance/FinanceContractTest.java | 8 +- .../FinanceFeeSourceEventServiceTest.java | 12 +- .../FinancePhase1OperationBehaviorTest.java | 44 +++---- .../FinancePhase1PermissionContractTest.java | 6 +- .../finance/FinanceReadApiBehaviorTest.java | 29 ++++- .../finance/FinanceServiceValidationTest.java | 53 -------- .../FinanceSettlementBehaviorTest.java | 8 +- .../FinanceSqlCommentContractTest.java | 2 +- .../finance/FinanceSupplierValidatorTest.java | 9 +- .../ReceivableStatisticsControllerTest.java | 39 ++++++ .../ReceivableStatisticsServiceTest.java | 64 ++++++++++ .../ReconciliationStatementBehaviorTest.java | 116 +++++++++++++++++- .../FinanceDocumentNumberGeneratorTest.java | 46 +++++++ .../FinanceExportProviderServiceTest.java | 30 ++++- .../InternalFinanceFeeSourceController.java | 11 +- .../firstleg/dto/ContainerFeeDetailDtos.java | 4 +- .../com/baiyue/firstleg/dto/FeeItemDtos.java | 13 +- .../dto/FinanceFeeSourceSnapshot.java | 6 +- .../firstleg/dto/InboundFeeDetailDtos.java | 6 +- .../dto/MerchantInboundBillingResponse.java | 2 +- .../entity/ContainerFeeDetailEntity.java | 6 +- .../ContainerFeeSourceSnapshotEntity.java | 39 ++++++ .../baiyue/firstleg/entity/FeeItemEntity.java | 2 + .../entity/InboundFeeDetailEntity.java | 6 +- .../ContainerFeeSourceSnapshotMapper.java | 9 ++ .../service/OceanBookingService.java | 4 +- .../ContainerFeeDetailRepository.java | 16 +++ .../ContainerFeeSourceSnapshotRepository.java | 48 ++++++++ .../repository/FeeItemRepository.java | 11 +- .../service/ContainerFeeDetailService.java | 66 ++++++---- .../firstleg/service/ContainerService.java | 3 +- .../firstleg/service/FeeItemService.java | 9 ++ .../service/FeeItemSupplierCategory.java | 34 +++++ .../FinanceFeeSourceSnapshotService.java | 46 +++---- .../FirstlegDocumentNumberGenerator.java | 55 +++++++++ .../service/InboundFeeDetailService.java | 51 ++++++-- .../MerchantInboundBillingService.java | 2 +- .../firstleg/service/SmartLoadingService.java | 3 +- .../service/TruckAppointmentService.java | 3 +- .../WarehouseSupplierSnapshotService.java | 8 ++ .../db/finance-fee-source-migration.sql | 58 ++++++--- .../db/phase2-fee-item-and-inbound-detail.sql | 6 +- ...se2-published-freight-detail-migration.sql | 5 +- .../db/phase3-smart-loading-schema.sql | 7 ++ .../resources/openapi/fba-pricing-v1.yaml | 16 +-- .../main/resources/openapi/fee-item-v1.yaml | 2 + .../main/resources/openapi/firstleg-v1.yaml | 11 +- .../resources/openapi/loading-lists-v1.yaml | 8 +- .../resources/openapi/ocean-bookings-v1.yaml | 13 +- .../openapi/phase3-container-overview-v1.yaml | 2 +- .../openapi/truck-appointments-v1.yaml | 9 +- .../controller/FeeManagementContractTest.java | 2 +- .../service/OceanBookingServiceTest.java | 4 + .../ContainerFeeDetailRepositoryTest.java | 2 +- ...FinanceFeeSourceMigrationContractTest.java | 2 +- .../SmartLoadingSqlContractTest.java | 1 + .../ContainerFeeDetailServiceTest.java | 11 +- .../service/ContainerServiceTest.java | 7 +- .../service/FeeManagementServiceTest.java | 16 ++- .../FinanceFeeSourceSnapshotServiceTest.java | 41 +++---- .../FirstlegDocumentNumberGeneratorTest.java | 48 ++++++++ .../service/SmartLoadingServiceTest.java | 18 +++ .../service/TruckAppointmentServiceTest.java | 3 + 91 files changed, 1538 insertions(+), 523 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java create mode 100644 finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java create mode 100644 finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerFeeSourceSnapshotMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemSupplierCategory.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index 4bcaee546..837438c33 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -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:} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index bbad36219..62818960e 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -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:} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 19b19d3b0..1230d9cf9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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和数据隔离键', diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 015e69f9b..d677f54ed 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -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: diff --git a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java index 8e8eef784..9df6f6712 100644 --- a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java +++ b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java @@ -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")))); diff --git a/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java index 074c74b17..3116463ee 100644 --- a/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java +++ b/finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java @@ -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 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 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) {} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java index a98216343..f266046d7 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java @@ -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 = "客户只读查询结算应收") diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java index c211c76fc..c21a6a710 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -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)); } } diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index 3f19b34f3..2d2e4862a 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -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 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) {} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java b/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java new file mode 100644 index 000000000..a8f88c608 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/ReceivableStatisticsController.java @@ -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 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"); + } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java index 48a01df0b..a25c3fb6c 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -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 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); diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java index d20dc6e9a..a93446a2c 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceReadModels.java @@ -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, diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java index 68008e5f8..4b4621849 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -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, diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java new file mode 100644 index 000000000..975be2937 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/ReceivableStatisticsDtos.java @@ -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 records) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java index 983354016..6c923a0af 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/ReconciliationStatementDtos.java @@ -15,6 +15,12 @@ public final class ReconciliationStatementDtos { public record ReconciliationActionRequest(String action, String reason, int expectedVersion, List 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 lines, List paymentHistory, diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java new file mode 100644 index 000000000..259ba7a6e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -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 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); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index 0de11cc3a..9b51034cb 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -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 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 SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED"); - private static final Set LEDGER_TYPES = Set.of("FEE_INCLUDED", "FEE_EXCLUDED", "RECONCILIATION_CREATED", + private static final Set 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 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 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 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> receivableStatisticsRows(Map 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> feeRows(Map 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 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 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 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 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 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); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java index e3d49e7fc..a0d93074d 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -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", "柜子归属不一致"); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 6630fbcf0..40ed549f8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -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 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 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 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); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java index 0f632ac83..3ba0a80bf 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -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 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 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 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 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, "账单不存在或不属于客户"); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index db9169ba3..e007b39fe 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -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 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> 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 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 lines) { - Set seen = new HashSet<>(); - for (BillLine line : lines) { - if (!seen.add(line.sourceId() + ":" + line.sourceVersion())) throw bad("同一费用来源版本不能重复加入账单"); - List> 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 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 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 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 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 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 lines) { lineTotal(lines); Map 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 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 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 voucherFileIds, String remark, List 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 voucherFileIds, String remark) {} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index f7dfcce87..30c69165e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -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> 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 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 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 source : sources) { @@ -185,7 +195,7 @@ public class FinanceSettlementService { SettlementSummary summary = summary(row); List 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 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 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); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java index ae96703f3..4f041aae9 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java @@ -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)) { diff --git a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java new file mode 100644 index 000000000..e3edb6d99 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java @@ -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 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 exportRows(YearMonth month, Long customerId) { + validate(month, customerId, 1, 1); + List rows = rows(month, customerId); + totals(rows); + return rows; + } + + private List 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 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 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) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index ca23f8833..cb2a5669e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -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 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 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 eligibleSources(CreateReconciliationRequest request) { List args = new ArrayList<>(); - StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?"); + 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> 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) {} } diff --git a/finance/src/main/resources/db/fee-source-event-migration.sql b/finance/src/main/resources/db/fee-source-event-migration.sql index f9d2487eb..fe7799e12 100644 --- a/finance/src/main/resources/db/fee-source-event-migration.sql +++ b/finance/src/main/resources/db/fee-source-event-migration.sql @@ -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 diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 4025469f7..e40c03549 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -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和数据隔离键', diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 2635ea67c..573be27cd 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -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"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 2cdebed9a..78cfdceab 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -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")); } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index 254760a3a..4b17bd2c4 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -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 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); } diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 27cebbbe0..c0fc03725 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -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 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; } diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java index 71320a29d..7bc05f727 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1PermissionContractTest.java @@ -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, diff --git a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java index 74df72371..258384877 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceReadApiBehaviorTest.java @@ -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')"); + } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 575a0a01b..23a19d81b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -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 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); - } } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index a7faa28c6..e41350778 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -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\"}"))); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 2f4cb0652..19965c989 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -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(); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java index 3eaf869f2..a0778650e 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java @@ -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 diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java new file mode 100644 index 000000000..c1cb850bf --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsControllerTest.java @@ -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"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java new file mode 100644 index 000000000..e583b3d81 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java @@ -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 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 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)); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index 44bfc1475..1064e9b0f 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -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 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 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 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 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); + } } diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..19c00dda8 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -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"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java index 3880bb18f..6ead12ece 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -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 parameters, String cursor, String snapshot) { return request(parameters, cursor, snapshot, 100); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java index 3c0c116ba..f56321de8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFinanceFeeSourceController.java @@ -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 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 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java index fe055f261..97d07a58e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java @@ -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 ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java index e5f608dbb..019125cae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java @@ -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); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java index 18ffe81a5..cf27da492 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FinanceFeeSourceSnapshot.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java index 4cbf5d28b..f74f31b23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java @@ -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); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java index d177903f4..b884526ed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -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 ) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java index 5a61d5f9d..6f6c4c8c6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java new file mode 100644 index 000000000..3a08d0b43 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java index 6a62b0bf6..2cc751bb3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java @@ -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; + } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java index f54a2cc98..02f9d1b84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerFeeSourceSnapshotMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerFeeSourceSnapshotMapper.java new file mode 100644 index 000000000..1fd0e14e8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerFeeSourceSnapshotMapper.java @@ -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 { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 41c6b8164..5db6c6f52 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java index 35474ffe2..d490ba2b7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java @@ -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() + .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() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java new file mode 100644 index 000000000..989377634 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java @@ -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() + .eq(ContainerFeeSourceSnapshotEntity::getLoadingPlanId, loadingPlanId) + .eq(ContainerFeeSourceSnapshotEntity::getSourceFeeId, sourceFeeId) + .eq(ContainerFeeSourceSnapshotEntity::getSourceVersion, sourceVersion)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java index 00c7ac710..0008d8859 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java @@ -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() + .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() .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)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java index 38852abab..92f047d11 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java @@ -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 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 9ff5ed97b..ea8d93f8f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -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()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java index f049883cb..21ebf3ced 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java @@ -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()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemSupplierCategory.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemSupplierCategory.java new file mode 100644 index 000000000..66edebdba --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemSupplierCategory.java @@ -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 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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java index 8036c1d8c..dbe62dccc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java @@ -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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java new file mode 100644 index 000000000..f1e20a633 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java @@ -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 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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 0662f143b..30835b175 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -29,6 +29,8 @@ public class InboundFeeDetailService { private FbaPricingRepository pricingRepository; @Resource private FinanceFeeSourceOutboxService financeOutbox; + @Resource + private WarehouseSupplierSnapshotService suppliers; @Transactional public List 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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 0a2f96699..d3693f300 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 3bb6bf678..d8d77120c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 29e7b732e..dbfdce6aa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -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 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);} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java index 4a498f761..03dfd2a2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java @@ -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) { diff --git a/firstleg/src/main/resources/db/finance-fee-source-migration.sql b/firstleg/src/main/resources/db/finance-fee-source-migration.sql index 67109767a..0e6712948 100644 --- a/firstleg/src/main/resources/db/finance-fee-source-migration.sql +++ b/firstleg/src/main/resources/db/finance-fee-source-migration.sql @@ -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 ; diff --git a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql index 10f26a5c0..c9f20dc4b 100644 --- a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql +++ b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql @@ -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 '国内国际快照', diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index 08bf9d82b..f296ad476 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -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; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 2d5f97d23..00e4d9dfd 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -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 '平台唯一货柜预订单号', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 90b3ded29..8c1bfa267 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -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} diff --git a/firstleg/src/main/resources/openapi/fee-item-v1.yaml b/firstleg/src/main/resources/openapi/fee-item-v1.yaml index 76988399a..3a752fc7c 100644 --- a/firstleg/src/main/resources/openapi/fee-item-v1.yaml +++ b/firstleg/src/main/resources/openapi/fee-item-v1.yaml @@ -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} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 65824e9f8..1e06431b0 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index b323df4ff..59c5fbc0d 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -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: 总毛重,单位千克} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index e107dda9a..363e22b73 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -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} diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index ab7ab24ef..2b7420a4d 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -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}}} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 2f54b5203..3c296e25a 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java index 6d49cccd2..c90ae47ef 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java @@ -58,7 +58,7 @@ class FeeManagementContractTest { .contains("ContainerFeeAdjustRequest:") .contains("required: [expectedSourceVersion, businessDate, amount]") .contains("additionalProperties: false") - .contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照") + .contains("费用方向、费用项目、往来单位、币种和备注保持原快照") .contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 8b30ace22..06019bc33 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -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()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java index 82e9a90ab..ebb267523 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java @@ -22,6 +22,6 @@ class ContainerFeeDetailRepositoryTest { "getReviewReason", "getUpdatedBy", "getUpdatedAt") .doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot", "getDirection", "getPartnerId", "getPartnerName", "getCurrency", - "getSettlementIncluded", "getRemark"); + "getRemark"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java index f74e30658..ce17047aa 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FinanceFeeSourceMigrationContractTest.java @@ -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迁移失败:入库费用往来单位快照字段不完整", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 7ef0a3fb5..d784cf5f6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java index a04eaff04..b5a7a3194 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -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 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, " 现有往来单位 ", "整柜拖车费"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 6b2b1d334..b3b6d3422 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -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("美西海运"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 13ec634f4..b8cf6f93e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java index 5d334f197..51c53c777 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotServiceTest.java @@ -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}"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..5b0e1af0a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 91e0a7bf0..a1974d345 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 81e014bcc..7b870552d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -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); }