From 761fe3686e7ce58e2366be79bfe8bb652303db97 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 26 Oct 2026 13:41:43 +0800 Subject: [PATCH 01/70] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/WalletSettlementReference.java | 13 +- .../com/baiyue/common/event/package-info.java | 7 + .../event/WalletSettlementReferenceTest.java | 22 + .../20261022-海外费用仓租数据库变更汇总.md | 39 ++ deploy/sql/README.md | 18 +- ...finance-overseas-fee-hash-width-repair.sql | 75 +++ ...fee-canonical-write-permissions-repair.sql | 121 ++++ deploy/sql/overseas-fee-remark-add.sql | 19 + .../overseas-storage-rent-zero-fee-repair.sql | 73 +++ ...ehouse-supplier-query-structure-repair.sql | 40 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 5 + deploy/sql/海外区域统一增量-MySQL8.sql | 127 ++++- finance/AGENTS.md | 2 +- .../PlatformFinanceWalletController.java | 26 + .../baiyue/finance/dto/FinanceWalletDtos.java | 3 + .../BusinessFeeSourceEventService.java | 29 +- .../openapi/finance-wallet-refund-v1.yaml | 61 ++ .../BusinessFeeSourceEventServiceTest.java | 78 +++ .../service/OverseasFeeWalletClosureTest.java | 183 ++++++ .../InternalFeeCatalogController.java | 58 ++ .../repository/FeeItemRepository.java | 12 + .../service/SharedFeeCatalogService.java | 53 ++ .../openapi/fee-catalog-internal-v1.yaml | 24 + .../service/SharedFeeCatalogServiceTest.java | 45 ++ .../resources/db/overseas-fee-permissions.sql | 50 ++ lastleg-service/docs/business-index.md | 11 +- .../overseas-fees-storage-rent-release.md | 167 ++++++ .../DomesticWarehouseDirectoryClient.java | 5 + .../client/FirstlegFeeCatalogClient.java | 27 + .../service/DeliveryTransferService.java | 45 +- .../service/DispatchDeliveryFees.java | 20 +- .../discard/pda/dto/PdaCargoDiscardDtos.java | 8 +- .../pda/service/PdaCargoDiscardService.java | 4 + .../discard/service/CargoDiscardService.java | 2 + .../service/DispatchAggregateService.java | 2 + .../service/DispatchPickingService.java | 5 + .../lastleg/dto/LastlegOrderRequests.java | 2 + .../client/OverseasFeeCustomerClient.java | 20 + .../OverseasFeeResponseJsonConfiguration.java | 44 ++ .../PlatformOverseasFeeCatalogController.java | 35 ++ .../PlatformOverseasFeeSourceController.java | 121 +++- .../financefee/dto/OverseasFeeSourceDtos.java | 19 +- .../financefee/entity/OverseasFeeFunding.java | 29 + .../entity/OverseasFeeRetryAudit.java | 25 + .../financefee/entity/OverseasFeeSource.java | 24 + .../mapper/OverseasFeeBusinessMapper.java | 104 ++++ .../mapper/OverseasFeeFundingMapper.java | 15 + .../mapper/OverseasFeeRetryMapper.java | 36 ++ .../mapper/OverseasManualFeeQueryMapper.java | 97 ++++ .../OverseasFeeBusinessRepository.java | 38 ++ .../OverseasFeeDeliveryRepository.java | 27 + .../OverseasFeeOutboxRepository.java | 4 + .../OverseasManualFeeRepository.java | 72 +++ .../financefee/service/FeeCatalogGateway.java | 67 +++ .../service/OverseasFeeBusinessGuard.java | 77 ++- .../service/OverseasFeeCustomerNames.java | 64 +++ .../service/OverseasFeeDeliveryService.java | 217 +++++++ .../service/OverseasFeeOutboxService.java | 2 + .../service/OverseasFeeSourceService.java | 84 ++- .../service/OverseasManualFeeService.java | 528 ++++++++++++++++++ .../service/StorageRentFeeWorkflow.java | 13 + .../service/InboundStockReceiptService.java | 5 + .../service/DestuffingPalletCostService.java | 27 +- ...PlatformOverseasProcurementController.java | 4 +- .../dto/OverseasProcurementRequests.java | 18 +- .../dto/OverseasProcurementResponses.java | 2 +- .../entity/OverseasProcurementOrder.java | 1 + .../service/OverseasProcurementService.java | 39 +- .../service/ProcurementControlService.java | 2 +- .../service/ProcurementFinanceService.java | 11 +- .../service/ProcurementLifecycleService.java | 2 +- .../service/ProcurementPhysicalService.java | 2 +- .../service/ProcurementQueryService.java | 9 +- .../service/ProcurementTransportService.java | 2 +- .../ProcurementWarehouseDirectory.java | 32 +- .../service/RelabelApplicationService.java | 53 +- .../service/ReturnSkuConversionService.java | 9 + .../entity/ReturnSkuConversion.java | 2 + .../service/ReturnCartonCountService.java | 5 + .../service/ReturnInventoryService.java | 5 + .../service/ReturnReceiptService.java | 4 + .../lastleg/service/LastlegOrderService.java | 15 +- .../client/CustomerStoragePricingClient.java | 15 + .../controller/PdaStorageRentController.java | 38 ++ .../PlatformStorageRentController.java | 50 ++ .../storagerent/dto/StorageRentDtos.java | 60 ++ .../entity/StorageRentBusinessSource.java | 11 + .../storagerent/entity/StorageRentLot.java | 55 ++ .../entity/StorageRentPreparation.java | 72 +++ .../storagerent/entity/StorageRentShare.java | 31 + .../gateway/StorageRentPricingGateway.java | 43 ++ .../StorageRentBusinessSourceMapper.java | 21 + .../mapper/StorageRentLotMapper.java | 19 + .../mapper/StorageRentPreparationMapper.java | 16 + .../mapper/StorageRentShareMapper.java | 9 + .../repository/StorageRentRepository.java | 170 ++++++ .../StorageRentRecoveryScheduler.java | 19 + .../service/StorageRentCalculator.java | 55 ++ .../service/StorageRentReviewService.java | 213 +++++++ .../service/StorageRentService.java | 480 ++++++++++++++++ .../service/StorageRentSourceResolver.java | 170 ++++++ .../StorageRentPreparationService.java | 38 ++ ...houseTransferRelabelCompletionService.java | 8 +- .../service/WarehouseTransferService.java | 8 + .../src/main/resources/db/lastleg.sql | 137 ++++- .../db/overseas-manual-fee-funding.sql | 22 + .../resources/db/return-sku-conversion.sql | 1 + .../src/main/resources/db/storage-rent.sql | 98 ++++ .../openapi/overseas-business-v1.yaml | 30 +- .../openapi/overseas-fee-catalog-v1.yaml | 21 + .../openapi/overseas-finance-fees-v1.yaml | 192 ++++++- .../openapi/procurement-fulfillment-v1.yaml | 5 +- .../resources/openapi/storage-rent-v1.yaml | 227 ++++++++ .../delivery/DispatchDeliveryFeesTest.java | 18 +- .../service/DeliveryTransferServiceTest.java | 17 +- .../service/RelabelOutboundPickingTest.java | 3 +- .../service/ReturnOutboundClosureTest.java | 2 + .../ReturnOutboundPdaPersistenceTest.java | 2 + .../service/PdaCargoDiscardServiceTest.java | 1 + .../service/CargoDiscardServiceTest.java | 1 + .../DispatchAggregatePersistenceTest.java | 1 + .../DispatchPickingPersistenceTest.java | 15 + .../DispatchRegionalCandidatesTest.java | 1 + .../service/DispatchRepairWorkflowTest.java | 3 +- .../financefee/FeeCatalogGatewayTest.java | 49 ++ ...verseasFeeCancellationPersistenceTest.java | 13 +- .../OverseasFeeResponseTimeTest.java | 34 ++ .../OverseasManualFeePersistenceTest.java | 229 ++++++++ ...tformOverseasFeeCatalogControllerTest.java | 39 ++ .../StorageRentZeroFeePersistenceTest.java | 77 +++ .../LegacyRelabelSettledFeeSourceTest.java | 49 ++ ...OverseasContainerFeeSourceServiceTest.java | 13 +- .../service/OverseasFeeBusinessGuardTest.java | 124 ++-- .../service/OverseasFeeCustomerNamesTest.java | 81 +++ .../OverseasFeeDeliveryServiceTest.java | 318 +++++++++++ .../service/OverseasManualFeeServiceTest.java | 513 +++++++++++++++++ .../ProcurementSettledFeeSourceTest.java | 22 +- .../InboundStockReceiptServiceTest.java | 1 + .../DestuffingPalletCostServiceTest.java | 23 +- .../OverseasProcurementServiceTest.java | 49 ++ .../ProcurementFinancePersistenceTest.java | 10 +- .../service/ProcurementQueryServiceTest.java | 9 + .../ProcurementTransportPersistenceTest.java | 31 +- .../ProcurementWarehouseDirectoryTest.java | 21 + .../RelabelApplicationServiceTest.java | 42 +- .../service/ReturnCartonCountServiceTest.java | 18 + .../service/ReturnInventoryServiceTest.java | 1 + .../service/ReturnReceiptServiceTest.java | 1 + .../ReturnSkuConversionPersistenceTest.java | 24 + .../service/LastlegOrderServiceTest.java | 46 +- .../StorageRentPricingGatewayTest.java | 56 ++ .../service/StorageRentCalculatorTest.java | 72 +++ .../StorageRentLegacyReturnCountTest.java | 115 ++++ .../service/StorageRentLineageTest.java | 138 +++++ .../service/StorageRentReviewSourceTest.java | 53 ++ .../service/StorageRentServiceTest.java | 470 ++++++++++++++++ .../StorageRentSourceResolverTest.java | 123 ++++ .../StorageRentPreparationServiceTest.java | 41 ++ .../service/TransferReturnSourceTest.java | 1 + .../WarehouseTransferCartonCountTest.java | 1 + ...rehouseTransferRelabelPersistenceTest.java | 1 + .../service/WarehouseTransferServiceTest.java | 1 + .../initialization-baseline-contract.test.mjs | 51 +- .../InternalOverseasStorageFeeController.java | 8 + .../warehouse/dto/OverseasStorageFeeDtos.java | 5 + .../OverseasStorageFeeRepository.java | 6 + .../service/OverseasStorageFeeService.java | 48 ++ .../main/resources/db/warehouse-supplier.sql | 1 + .../main/resources/openapi/warehouse-v1.yaml | 50 ++ ...lOverseasStoragePricingControllerTest.java | 28 + .../service/OverseasStoragePricingTest.java | 140 +++++ 171 files changed, 8750 insertions(+), 386 deletions(-) create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/WalletSettlementReferenceTest.java create mode 100644 deploy/sql/20261022-海外费用仓租数据库变更汇总.md create mode 100644 deploy/sql/finance-overseas-fee-hash-width-repair.sql create mode 100644 deploy/sql/overseas-fee-canonical-write-permissions-repair.sql create mode 100644 deploy/sql/overseas-fee-remark-add.sql create mode 100644 deploy/sql/overseas-storage-rent-zero-fee-repair.sql create mode 100644 deploy/sql/warehouse-supplier-query-structure-repair.sql create mode 100644 finance/src/main/resources/openapi/finance-wallet-refund-v1.yaml create mode 100644 finance/src/test/java/com/baiyue/finance/service/OverseasFeeWalletClosureTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFeeCatalogController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/SharedFeeCatalogService.java create mode 100644 firstleg/src/main/resources/openapi/fee-catalog-internal-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/SharedFeeCatalogServiceTest.java create mode 100644 iam/src/main/resources/db/overseas-fee-permissions.sql create mode 100644 lastleg-service/docs/overseas-fees-storage-rent-release.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegFeeCatalogClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/client/OverseasFeeCustomerClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/config/OverseasFeeResponseJsonConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeCatalogController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeFunding.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeRetryAudit.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeFundingMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeRetryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasManualFeeQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeDeliveryRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasManualFeeRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/FeeCatalogGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNames.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/StorageRentFeeWorkflow.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/client/CustomerStoragePricingClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PdaStorageRentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PlatformStorageRentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentLot.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentPreparation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentShare.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentLotMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentPreparationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentShareMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/scheduler/StorageRentRecoveryScheduler.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationService.java create mode 100644 lastleg-service/src/main/resources/db/overseas-manual-fee-funding.sql create mode 100644 lastleg-service/src/main/resources/db/storage-rent.sql create mode 100644 lastleg-service/src/main/resources/openapi/overseas-fee-catalog-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/storage-rent-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/FeeCatalogGatewayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeResponseTimeTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeCatalogControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/StorageRentZeroFeePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/LegacyRelabelSettledFeeSourceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNamesTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGatewayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculatorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLegacyReturnCountTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLineageTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewSourceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationServiceTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/InternalOverseasStoragePricingControllerTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStoragePricingTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java index 71fe1fd86..5824c0f62 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java @@ -1,4 +1,13 @@ package com.baiyue.common.event; -/** Finance-issued identity of a successful consumption, carried by its immutable fee source. */ -public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { } +/** 成功扣款凭据;资金业务身份独立于费用对应的实际服务订单。 */ +public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId, + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + String walletBusinessType, + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + String walletBusinessNo) { + /** 原有资金身份与服务订单一致的来源继续使用原三项凭据。 */ + public WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { + this(commandEventId, holdId, ledgerId, null, null); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/package-info.java b/common-event-contract/src/main/java/com/baiyue/common/event/package-info.java index 785251b9d..41089c3f4 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/package-info.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/package-info.java @@ -1,4 +1,11 @@ /** * 国内与海外业务单元共享的稳定事件契约。 + * + *

海外费用的 walletSettlement 引用成功扣款的 commandEventId、holdId、ledgerId。 + * 独立资金流程另外携带 walletBusinessType、walletBusinessNo:人工费用为 + * OVERSEAS_MANUAL_FEE / 来源费用ID-冻结次数,仓租为 STORAGE_RENT / 出库预占ID。 + * 外层 businessType、businessNo 始终表示实际服务订单,不能替换为资金流程编号。 + * Finance 同时核对冻结、实扣流水、成功指令结果及客户金额,一个 hold 只能关联一个费用来源。 + * 资金身份与服务订单一致的既有引用省略这两个字段,保持既有快照哈希。

*/ package com.baiyue.common.event; diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/WalletSettlementReferenceTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/WalletSettlementReferenceTest.java new file mode 100644 index 000000000..1183d7d88 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/WalletSettlementReferenceTest.java @@ -0,0 +1,22 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class WalletSettlementReferenceTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void oldReferencesRetainExactHashShape() throws Exception { + String original = "{\"commandEventId\":\"c1\",\"holdId\":\"9\",\"ledgerId\":\"72\"}"; + var reference = json.readValue(original, WalletSettlementReference.class); + assertThat(json.readTree(json.writeValueAsString(reference))).isEqualTo(json.readTree(original)); + } + + @Test + void dedicatedFundingIdentitySurvivesRoundTrip() throws Exception { + var reference = new WalletSettlementReference("c1", "9", "72", "STORAGE_RENT", "445"); + assertThat(json.readValue(json.writeValueAsString(reference), WalletSettlementReference.class)).isEqualTo(reference); + } +} diff --git a/deploy/sql/20261022-海外费用仓租数据库变更汇总.md b/deploy/sql/20261022-海外费用仓租数据库变更汇总.md new file mode 100644 index 000000000..74645a113 --- /dev/null +++ b/deploy/sql/20261022-海外费用仓租数据库变更汇总.md @@ -0,0 +1,39 @@ +# 海外费用与仓租数据库定义汇总 + +本次功能的数据库定义已统一到下面两份 SQL,五份重复版本脚本和两份专项汇总 SQL 已移除。仅整理文件,未连接数据库、执行 SQL 或部署。 + +## 两个统一入口 + +| 文件 | 相关数据库 | 本次内容 | +|---|---|---| +| [头程数据库统一增量-MySQL8.sql](头程数据库统一增量-MySQL8.sql) | `baiyue_iam` | 海外费用管理菜单及默认平台管理员授权;保留既有财务编辑、审核按钮和授权 | +| [海外区域统一增量-MySQL8.sql](海外区域统一增量-MySQL8.sql) | `baiyue_lastleg` | 资金与仓租四表、费用关联及换标快照、审核人名称、采购退款请求幂等字段与唯一索引 | + +两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `CREATE TABLE` 中,菜单及授权使用初始化种子;不追加存量改造、权限版本回填或动态迁移段。目标 schema 必须为空,按文件原有初始化流程执行;不能向已有业务库重放。 + +本次不再提供这项功能的独立升级脚本。已有库升级必须按实际结构和明确授权另行准备、审阅受控增量,不能把删除重复文件理解为已有库已完成升级。 + +## 海外表和字段 + +| 类型 | 表 / 字段 | 用途 | +|---|---|---| +| 新表 | `ll_finance_fee_funding` | 人工费用冻结、审核扣款、释放及财务流水引用 | +| 新表 | `ll_storage_rent_lot` | 实际入库计租原批次、数量及入库时间 | +| 新表 | `ll_storage_rent_inbound` | 头程每次实收凭证去重,关联同单同仓计租批次 | +| 新表 | `ll_storage_rent_share` | 库存与原计租批次份额关联,保留原库龄 | +| 新表 | `ll_storage_rent_preparation` | 出库计租快照、汇率、资金预占及实际出库记录 | +| 可空字段 | `ll_destuff_pallet_cost_allocation.fee_source_id` | `BIGINT NULL`,允许人工审核后关联海外费用来源 | +| 字段 | `ll_return_sku_conversion.storage_rent_shares_json` | `LONGTEXT NULL`,退货 SKU 换标的原仓租份额快照 | +| 字段 | `ll_finance_fee_source.reviewer_name` | `VARCHAR(255) NULL`,审核人名称快照 | +| 字段 | `ll_overseas_procurement_fee.idempotency_key` | `VARCHAR(128) NULL`,采购退款请求幂等键 | +| 唯一索引 | `uk_ll_procurement_refund_request(order_id,purpose,idempotency_key)` | 同一采购订单、用途和退款请求去重 | + +## 国内菜单和权限 + +海外业务 → 海外费用管理,菜单 ID `5117200`、权限 `platform:overseas-fee:list`、路由 `/overseas-business/fees`;默认仅授予 `PLATFORM_ADMIN`。编辑和审核分别复用 `platform:finance:fee-source:edit` 与 `platform:finance:fee-source:review`,列表权限不包含写操作。国内基线已包含两项既有按钮及管理员授权。 + +已有库的 [费用写权限修复](overseas-fee-canonical-write-permissions-repair.sql) 是单独的定向补丁,继续保留原适用条件和授权边界,不是结构升级或菜单初始化入口。其他独立修复脚本与历史迁移不在本次清理范围。 + +费用项目继续使用 Firstleg 现有目录及 `FEE_TYPE_077 / 仓储费`,不重复插入项目、不批量覆盖客户专价、不回填历史仓租或费用、不修改钱包余额。 + +配套服务、页面、配置及验收边界见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。 diff --git a/deploy/sql/README.md b/deploy/sql/README.md index fda18d5bf..5674b6160 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -4,6 +4,14 @@ 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 +`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。 + +`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。 + +`finance-overseas-fee-hash-width-repair.sql` 用于明确授权的本机开发环境海外费用推送修复,固定目标 `baiyue_finance`。先核对 `fin_inbox_event.payload_hash`、`fin_fee_source.source_hash` 两列;仅将不足71位的长度扩至71位,兼容 `sha256:` 前缀,保留空值约束和字符集排序规则,不更新业务数据。缺列、异常类型或小于64位时停止;已有足够长度则跳过。使用独立连接、遇错停止,执行后回查结构,再通过原费用事件重试验证,禁止重新生成费用或扣款。脚本包含执行后移除的专用校验过程;创建或执行失败须停止,不扩大修复范围。文件准备不表示已执行。 + +`warehouse-supplier-query-structure-repair.sql` 用于明确授权的本机开发环境供应商查询结构修复,固定目标 `baiyue_warehouse`:仅在缺失时新增 `wh_supplier.trucking_scope`,并按当前模块基线创建缺失的 `wh_supplier_carrier` 空表。结构存在则跳过,不更新供应商类别、费用或资金,不推断历史拖车的国内/海外归属。使用独立连接、遇错停止,执行后回查结构并通过正式供应商列表 API 验证;不需要重启服务。文件准备不表示已获授权或已执行。 + 仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。 排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。 @@ -69,7 +77,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -92,7 +100,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -192,3 +200,9 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test ## 统一业务单号 编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。 + +海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照、审核人名称,以及采购退款请求幂等字段和唯一索引;同步模块源基线、国内权限种子及海外空库 238 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。 + +本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 + +采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。 diff --git a/deploy/sql/finance-overseas-fee-hash-width-repair.sql b/deploy/sql/finance-overseas-fee-hash-width-repair.sql new file mode 100644 index 000000000..a04ca476f --- /dev/null +++ b/deploy/sql/finance-overseas-fee-hash-width-repair.sql @@ -0,0 +1,75 @@ +-- 本机开发环境海外费用推送结构修复。固定目标:baiyue_finance。 +-- 仅将两个既有摘要字段不足 71 位的长度扩至 71;不改数据、费用、账单或余额。 +-- 保留原字段的空值约束、字符集及排序规则;缺表、缺列或异常字段类型时拒绝执行。 +-- 执行须单独授权。使用独立连接、遇错停止;不得以本文件代替完整数据库初始化。 +USE baiyue_finance; + +-- 执行前只读核对;sha256: 前缀 7 位加摘要 64 位,共 71 位。 +SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, + CHARACTER_SET_NAME, COLLATION_NAME +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'baiyue_finance' + AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash') + OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash')); + +DELIMITER $$ +CREATE PROCEDURE finance_fee_hash_width_repair_20261023() +BEGIN + DECLARE checked_columns INT DEFAULT 0; + DECLARE column_index INT DEFAULT 0; + DECLARE target_table VARCHAR(64); + DECLARE target_column VARCHAR(64); + DECLARE current_length BIGINT; + DECLARE nullable_value VARCHAR(3); + DECLARE charset_value VARCHAR(64); + DECLARE collation_value VARCHAR(64); + DECLARE comment_value TEXT; + + IF DATABASE() <> 'baiyue_finance' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '目标必须是 baiyue_finance'; + END IF; + SELECT COUNT(*) INTO checked_columns + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'baiyue_finance' AND DATA_TYPE IN ('char', 'varchar') + AND CHARACTER_MAXIMUM_LENGTH >= 64 + AND COLUMN_DEFAULT IS NULL AND EXTRA = '' + AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash') + OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash')); + IF checked_columns <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '摘要字段缺失或类型异常,停止修复'; + END IF; + + WHILE column_index < 2 DO + SET target_table = IF(column_index = 0, 'fin_inbox_event', 'fin_fee_source'); + SET target_column = IF(column_index = 0, 'payload_hash', 'source_hash'); + SELECT CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_COMMENT + INTO current_length, nullable_value, charset_value, collation_value, comment_value + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'baiyue_finance' AND TABLE_NAME = target_table + AND COLUMN_NAME = target_column; + IF current_length < 71 THEN + SET @finance_fee_hash_width_ddl = CONCAT( + 'ALTER TABLE baiyue_finance.`', target_table, '` MODIFY COLUMN `', target_column, + '` VARCHAR(71) CHARACTER SET ', charset_value, ' COLLATE ', collation_value, + IF(nullable_value = 'YES', ' NULL', ' NOT NULL'), + ' COMMENT ', QUOTE(comment_value)); + PREPARE finance_fee_hash_width_stmt FROM @finance_fee_hash_width_ddl; + EXECUTE finance_fee_hash_width_stmt; + DEALLOCATE PREPARE finance_fee_hash_width_stmt; + END IF; + SET column_index = column_index + 1; + END WHILE; +END$$ +DELIMITER ; + +CALL finance_fee_hash_width_repair_20261023(); +DROP PROCEDURE finance_fee_hash_width_repair_20261023; +SET @finance_fee_hash_width_ddl = NULL; + +-- 执行后两列长度均应至少为 71;结构已正确时不执行 ALTER。 +SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, + CHARACTER_MAXIMUM_LENGTH >= 71 AS width_ok +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'baiyue_finance' + AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash') + OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash')); diff --git a/deploy/sql/overseas-fee-canonical-write-permissions-repair.sql b/deploy/sql/overseas-fee-canonical-write-permissions-repair.sql new file mode 100644 index 000000000..f4b432857 --- /dev/null +++ b/deploy/sql/overseas-fee-canonical-write-permissions-repair.sql @@ -0,0 +1,121 @@ +-- 海外费用既有写按钮最小修复;MySQL 8,目标只能是 baiyue_iam。 +-- 仅恢复基线 5105024/5105025 及既有 4000001 PLATFORM_ADMIN 缺失授权。 +-- 不创建角色/成员,不授予其他角色,不新增 overseas-fee:edit/review 别名。 +-- 在支持 DELIMITER、遇错停止的客户端同连接整份执行;执行期间暂停相关角色权限维护。 +-- 不在其他未提交事务中调用。所有业务DML及subject版本变更同事务,任何异常回滚。 +USE baiyue_iam; +SET NAMES utf8mb4; +SELECT DATABASE() AS target_database; + +DROP PROCEDURE IF EXISTS repair_overseas_fee_canonical_write; +DELIMITER $$ +CREATE PROCEDURE repair_overseas_fee_canonical_write() +BEGIN + DECLARE locked_role BIGINT; + DECLARE changed_rows BIGINT DEFAULT 0; + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + + START TRANSACTION; + IF DATABASE()<>'baiyue_iam' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Wrong schema: expected baiyue_iam'; + END IF; + IF (SELECT COUNT(*) FROM iam_role WHERE id=4000001 AND tenant_id=0 AND app_code='PLATFORM' + AND role_code='PLATFORM_ADMIN' AND role_type='SYSTEM' AND status='ACTIVE' AND deleted=0)<>1 + OR EXISTS(SELECT 1 FROM iam_role WHERE role_code='PLATFORM_ADMIN' AND id<>4000001) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing PLATFORM_ADMIN identity/status mismatch; no role will be created'; + END IF; + SELECT id INTO locked_role FROM iam_role WHERE id=4000001 FOR UPDATE; + + -- 新建缺失按钮使用当前客户应收父菜单;只接受已核对的既有财务菜单,不修复其他菜单。 + IF (SELECT COUNT(*) FROM iam_permission WHERE id=5105041 AND tenant_id=0 AND app_code='PLATFORM' + AND permission_type='MENU' AND permission_code='platform:finance:customer-arap:list' + AND status='ACTIVE' AND deleted=0)<>1 + OR EXISTS(SELECT 1 FROM iam_permission WHERE permission_code='platform:finance:customer-arap:list' AND id<>5105041) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing customer AR/AP parent menu is missing or mismatched'; + END IF; + IF EXISTS(SELECT 1 FROM iam_permission WHERE + (id=5105024 AND NOT(permission_code<=>'platform:finance:fee-source:review')) + OR (id=5105025 AND NOT(permission_code<=>'platform:finance:fee-source:edit')) + OR (permission_code='platform:finance:fee-source:review' AND id<>5105024) + OR (permission_code='platform:finance:fee-source:edit' AND id<>5105025) + OR (id IN(5105024,5105025) AND (tenant_id<>0 OR app_code<>'PLATFORM' + OR permission_type<>'BUTTON' OR built_in<>1 OR parent_id NOT IN(5105000,5105041) + OR route_path IS NOT NULL + OR NOT(http_method<=>IF(id=5105024,'POST','PUT'))))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Canonical fee button identity/type/metadata conflict'; + END IF; + IF EXISTS(SELECT 1 FROM iam_permission child LEFT JOIN iam_permission parent ON parent.id=child.parent_id + WHERE child.id IN(5105024,5105025) AND (parent.id IS NULL OR parent.tenant_id<>0 + OR parent.app_code<>'PLATFORM' OR parent.permission_type<>'MENU' OR parent.status<>'ACTIVE' OR parent.deleted<>0 + OR NOT(parent.permission_code<=>IF(child.parent_id=5105000,'platform:finance','platform:finance:customer-arap:list')))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing fee button parent is invalid'; + END IF; + IF EXISTS(SELECT 1 FROM iam_role_permission WHERE permission_id IN(5105024,5105025) AND tenant_id<>0) + OR EXISTS(SELECT 1 FROM iam_membership_role mr LEFT JOIN iam_membership m ON m.id=mr.membership_id + WHERE mr.role_id=4000001 AND (mr.tenant_id<>0 OR m.id IS NULL OR m.tenant_id<>0)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing canonical permission or administrator membership scope mismatch'; + END IF; + -- 恢复停用/缺失资源也会恢复其他角色的旧授权;这种情况必须单独核对,不能随本补丁扩权。 + IF EXISTS(SELECT 1 FROM iam_role_permission rp LEFT JOIN iam_permission p ON p.id=rp.permission_id + WHERE rp.permission_id IN(5105024,5105025) AND rp.role_id<>4000001 + AND (p.id IS NULL OR p.status<>'ACTIVE' OR p.deleted<>0)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Restoring fee button would affect another role; review separately'; + END IF; + + INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) + SELECT 5105024,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:review','审核财务费用来源',NULL,'POST',150,'ACTIVE',1,0 + WHERE NOT EXISTS(SELECT 1 FROM iam_permission WHERE id=5105024); + SET changed_rows=changed_rows+ROW_COUNT(); + INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) + SELECT 5105025,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:edit','编辑财务费用来源',NULL,'PUT',160,'ACTIVE',1,0 + WHERE NOT EXISTS(SELECT 1 FROM iam_permission WHERE id=5105025); + SET changed_rows=changed_rows+ROW_COUNT(); + UPDATE iam_permission SET status='ACTIVE',deleted=0 + WHERE id IN(5105024,5105025) AND (status<>'ACTIVE' OR deleted<>0); + SET changed_rows=changed_rows+ROW_COUNT(); + INSERT INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) + SELECT 0,4000001,p.id,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.id IN(5105024,5105025) + AND NOT EXISTS(SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id); + SET changed_rows=changed_rows+ROW_COUNT(); + + -- 只有真实变更才推进版本;枚举所有既有管理员成员,不依赖当前登录用户,也不遗漏停用成员。 + IF changed_rows>0 THEN + INSERT INTO iam_security_subject_version(auth_user_id,permission_version) + SELECT DISTINCT u.auth_user_id,1 FROM iam_user u + JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0 + JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id AND mr.role_id=4000001 + WHERE u.auth_user_id IS NOT NULL + ON DUPLICATE KEY UPDATE permission_version=permission_version+1; + END IF; + IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id + WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.id IN(5105024,5105025) + AND p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON' + AND p.status='ACTIVE' AND p.deleted=0)<>2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Canonical fee write grants incomplete; repair rolled back'; + END IF; + COMMIT; + SELECT changed_rows AS repaired_catalog_or_grant_rows; +END$$ +DELIMITER ; +CALL repair_overseas_fee_canonical_write(); +DROP PROCEDURE repair_overseas_fee_canonical_write; + +-- 核验1:必须恰好两行,角色4000001、tenant=0、BUTTON、ACTIVE、deleted=0。 +SELECT r.id AS role_id,r.role_code,rp.tenant_id,p.id AS permission_id,p.permission_code, + p.permission_type,p.status,p.deleted +FROM iam_role r JOIN iam_role_permission rp ON rp.role_id=r.id +JOIN iam_permission p ON p.id=rp.permission_id +WHERE r.id=4000001 AND p.id IN(5105024,5105025) ORDER BY p.id; +-- 核验2:仅查看既有管理员主体与当前版本,不创建或切换任何成员角色。 +SELECT DISTINCT u.auth_user_id,m.id AS membership_id,u.status AS user_status,m.status AS membership_status, + COALESCE(sv.permission_version,0) AS permission_version +FROM iam_user u JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0 +JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id AND mr.role_id=4000001 +LEFT JOIN iam_security_subject_version sv ON sv.auth_user_id=u.auth_user_id +ORDER BY u.auth_user_id,m.id; diff --git a/deploy/sql/overseas-fee-remark-add.sql b/deploy/sql/overseas-fee-remark-add.sql new file mode 100644 index 000000000..10efb6dd9 --- /dev/null +++ b/deploy/sql/overseas-fee-remark-add.sql @@ -0,0 +1,19 @@ +-- 海外人工费用说明字段。仅修改 baiyue_lastleg.ll_finance_fee_source 的结构。 +-- 执行需单独授权;使用独立连接、遇错停止。先执行本脚本,再加载配套 Lastleg 代码。 +-- 无业务数据回填、费用重建、资金操作或菜单变更。已有字段时跳过新增。 +USE baiyue_lastleg; + +SET @overseas_fee_remark_ddl = ( + SELECT IF(EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_finance_fee_source' AND column_name = 'fee_remark' + ), 'SELECT ''fee_remark already exists'' AS result', + 'ALTER TABLE ll_finance_fee_source ADD COLUMN fee_remark VARCHAR(500) NULL COMMENT ''费用说明,补收或重复作业原因'' AFTER fee_item_name') +); +PREPARE overseas_fee_remark_stmt FROM @overseas_fee_remark_ddl; +EXECUTE overseas_fee_remark_stmt; +DEALLOCATE PREPARE overseas_fee_remark_stmt; + +SELECT table_schema, table_name, column_name, column_type, is_nullable, column_comment +FROM information_schema.columns +WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_finance_fee_source' AND column_name = 'fee_remark'; diff --git a/deploy/sql/overseas-storage-rent-zero-fee-repair.sql b/deploy/sql/overseas-storage-rent-zero-fee-repair.sql new file mode 100644 index 000000000..6eadb4cd1 --- /dev/null +++ b/deploy/sql/overseas-storage-rent-zero-fee-repair.sql @@ -0,0 +1,73 @@ +-- 固定目标本机 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。 +-- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。 +USE baiyue_lastleg; +SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' + AND COLUMN_NAME IN ('amount','original_amount','exchange_rate','created_by','fee_item_code','direction'); + +-- 上次 CALL 前置检查失败可能遗留本专用过程,仅清理本脚本自己的过程后重建。 +DROP PROCEDURE IF EXISTS storage_rent_zero_fee_repair_20261023; +DELIMITER $$ +CREATE PROCEDURE storage_rent_zero_fee_repair_20261023() +BEGIN + DECLARE checked_columns INT DEFAULT 0; + DECLARE checked_constraints INT DEFAULT 0; + DECLARE rate_type VARCHAR(64); + DECLARE rate_comment TEXT; + DECLARE rate_default TEXT; + DECLARE rate_default_sql TEXT; + IF DATABASE()<>'baiyue_lastleg' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg'; + END IF; + SELECT COUNT(*) INTO checked_columns FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' + AND ((COLUMN_NAME IN ('amount','original_amount') AND DATA_TYPE='decimal' AND IS_NULLABLE='NO') + OR (COLUMN_NAME='exchange_rate' AND DATA_TYPE='decimal' AND EXTRA='') + OR (COLUMN_NAME IN ('created_by','fee_item_code','direction') AND DATA_TYPE='varchar' AND IS_NULLABLE='NO')); + SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' + AND CONSTRAINT_NAME='chk_ll_finance_fee_amount' AND CONSTRAINT_TYPE='CHECK'; + IF checked_columns<>6 OR checked_constraints<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用源表字段或金额约束异常,停止修复'; + END IF; + -- 增加新约束前只读检查;人工费用仍须正额且有效汇率,防止意外放宽。 + IF EXISTS (SELECT 1 FROM ll_finance_fee_source + WHERE NOT ((amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')) + AND ((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在不满足新约束的费用,停止且不改数据'; + END IF; + SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' + AND CONSTRAINT_NAME='chk_ll_finance_fee_exchange_rate' AND CONSTRAINT_TYPE='CHECK'; + IF checked_constraints=0 THEN + -- 保留真实已有列的精度和备注,不把存量 DECIMAL(18,8) 收窄到基线精度。 + SELECT COLUMN_TYPE,COLUMN_COMMENT,COLUMN_DEFAULT INTO rate_type,rate_comment,rate_default + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='exchange_rate'; + IF rate_type NOT REGEXP '^decimal[(][0-9]+,[0-9]+[)]$' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率列不是预期定点数类型,停止修复'; + END IF; + IF LOCATE('自动零元仓租可空',rate_comment)=0 THEN + SET rate_comment=CONCAT(rate_comment,',自动零元仓租可空'); + END IF; + -- 保留既有数值默认值(例如1.00000000);拒绝表达式或非数值默认值。 + IF rate_default IS NOT NULL AND rate_default NOT REGEXP '^-?[0-9]+([.][0-9]+)?$' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率默认值不是预期数值,停止修复'; + END IF; + SET rate_default_sql=IF(rate_default IS NULL,' DEFAULT NULL',CONCAT(' DEFAULT ',rate_default)); + -- 单次原子 ALTER 同步调整可空性与两项约束,原币及结算列完全不改。 + SET @storage_rent_zero_fee_ddl=CONCAT( + 'ALTER TABLE baiyue_lastleg.ll_finance_fee_source DROP CHECK chk_ll_finance_fee_amount, ', + 'MODIFY COLUMN exchange_rate ',rate_type,' NULL',rate_default_sql,' COMMENT ',QUOTE(rate_comment),', ', + 'ADD CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE'')), ', + 'ADD CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE''))'); + PREPARE storage_rent_zero_fee_stmt FROM @storage_rent_zero_fee_ddl; + EXECUTE storage_rent_zero_fee_stmt; + DEALLOCATE PREPARE storage_rent_zero_fee_stmt; + SET @storage_rent_zero_fee_ddl=NULL; + END IF; +END$$ +DELIMITER ; +CALL storage_rent_zero_fee_repair_20261023(); +DROP PROCEDURE storage_rent_zero_fee_repair_20261023; +SHOW CREATE TABLE ll_finance_fee_source; diff --git a/deploy/sql/warehouse-supplier-query-structure-repair.sql b/deploy/sql/warehouse-supplier-query-structure-repair.sql new file mode 100644 index 000000000..41cff3c89 --- /dev/null +++ b/deploy/sql/warehouse-supplier-query-structure-repair.sql @@ -0,0 +1,40 @@ +-- 供应商查询缺失结构修复。MySQL 8;仅用于本次明确授权的本机开发库。 +-- 固定目标 baiyue_warehouse;不修改供应商、费用、余额或授信数据。 +-- 已有结构安全跳过;不推断历史拖车供应商的国内/海外类别。 +-- DDL 会隐式提交,请在独立连接以遇错停止模式整份执行。 +USE baiyue_warehouse; +SET NAMES utf8mb4; +SELECT DATABASE() AS target_database; + +-- 先确认正式供应商主表存在;不存在时 ALTER 会报错并停止,不创建第二份主档。 +SET @supplier_query_column_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_warehouse' + AND table_name = 'wh_supplier' AND column_name = 'trucking_scope'), + 'SELECT ''trucking_scope 已存在,跳过'' AS structure_result', + 'ALTER TABLE baiyue_warehouse.wh_supplier ADD COLUMN trucking_scope VARCHAR(16) NULL COMMENT ''拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空'' AFTER category' +); +PREPARE supplier_query_statement FROM @supplier_query_column_ddl; +EXECUTE supplier_query_statement; +DEALLOCATE PREPARE supplier_query_statement; + +-- 正式列表还会读取船司关联;若未初始化,仅按当前模块基线建立空表。 +CREATE TABLE IF NOT EXISTS wh_supplier_carrier ( + supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'BOOKING供应商ID', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', + carrier_code VARCHAR(16) NOT NULL COMMENT '船司标准编码', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (supplier_id,carrier_code), + KEY idx_wh_supplier_carrier_code (tenant_id,carrier_code,supplier_id), + CONSTRAINT chk_wh_supplier_carrier_tenant CHECK (tenant_id = 0), + CONSTRAINT chk_wh_supplier_carrier_code CHECK (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BOOKING供应商与标准船司编码多对多关联'; + +-- 执行后只核对结构,不读取供应商联系信息。 +SELECT table_name, column_name, column_type, is_nullable +FROM information_schema.columns +WHERE table_schema = 'baiyue_warehouse' + AND ((table_name = 'wh_supplier' AND column_name = 'trucking_scope') + OR table_name = 'wh_supplier_carrier') +ORDER BY table_name, ordinal_position; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8d6c1cedb..cadcdd050 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1,4 +1,5 @@ -- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 +-- 海外费用菜单及平台管理员授权统一在本文件 IAM 种子中维护,不再单列本次版本或专项菜单脚本。 -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 @@ -8458,4 +8459,8 @@ CREATE TABLE wh_document_sequence ( CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; +-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。 +USE baiyue_iam; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3)); SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 14527036f..dd8a4fd9b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,4 +1,5 @@ -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 +-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。 -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 @@ -314,7 +315,7 @@ CREATE TABLE ll_destuff_pallet_cost_allocation ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积', allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额', - fee_source_id bigint NOT NULL COMMENT '海外费用来源ID', + fee_source_id bigint DEFAULT NULL COMMENT '人工审核后关联的海外费用来源ID', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id), @@ -947,6 +948,7 @@ CREATE TABLE ll_finance_fee_source ( warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID', fee_item_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照', fee_item_name varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照', + fee_remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用说明,补收或重复作业原因', direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID', supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照', @@ -962,7 +964,7 @@ CREATE TABLE ll_finance_fee_source ( amount decimal(20,6) NOT NULL COMMENT '人民币结算金额', original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD', original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额', - exchange_rate decimal(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1', + exchange_rate decimal(20,6) DEFAULT NULL COMMENT '原币折算人民币汇率,人民币固定为1,自动零元仓租可空', exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM', chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数', quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位', @@ -974,6 +976,7 @@ CREATE TABLE ll_finance_fee_source ( source_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', review_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因', reviewer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + reviewer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人名称快照', reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID', updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID', @@ -984,7 +987,8 @@ CREATE TABLE ll_finance_fee_source ( KEY idx_ll_finance_fee_customer (customer_id,source_status,created_at), KEY idx_ll_finance_fee_business (business_type,business_no,created_at), KEY idx_ll_finance_fee_container_customer (container_booking_id,customer_id,source_status,created_at), - CONSTRAINT chk_ll_finance_fee_amount CHECK ((amount > 0)), + CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')), CONSTRAINT chk_ll_finance_fee_currency CHECK ((currency = _utf8mb4'CNY')), CONSTRAINT chk_ll_finance_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), CONSTRAINT chk_ll_finance_fee_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))), @@ -2122,6 +2126,7 @@ CREATE TABLE ll_overseas_procurement_file ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证'; CREATE TABLE ll_overseas_procurement_order ( + procurement_direction VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS_TO_DOMESTIC' COMMENT '采购方向:海外采购回国内/国内采购发海外', id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', @@ -4593,6 +4598,7 @@ CREATE TABLE ll_return_sku_conversion ( quantity BIGINT NOT NULL COMMENT '实际完成转换件数', status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回', ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回', + storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照', completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间', confirmed_at DATETIME(3) NULL COMMENT '客户确认时间', reversed_at DATETIME(3) NULL COMMENT '退回冲回时间', @@ -4987,4 +4993,117 @@ CREATE TABLE ll_document_sequence ( CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables; +-- 海外费用资金及实收仓租。 +-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。 +CREATE TABLE ll_finance_fee_funding ( + source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID', + customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识', + collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信', + wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态', + wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号', + command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID', + command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账', + error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空', + command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执', + created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间', + PRIMARY KEY (source_fee_id), + KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status), + KEY idx_ll_fee_funding_command (command_event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排'; + +CREATE TABLE IF NOT EXISTS ll_storage_rent_inbound ( + receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量', + lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键', + quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数', + created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时', + PRIMARY KEY (receipt_id), + KEY idx_storage_rent_inbound_lot (lot_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记'; + +CREATE TABLE ll_storage_rent_lot ( + id bigint NOT NULL COMMENT '原批次主键', + source_key varchar(80) NOT NULL COMMENT '实收来源幂等键', + customer_id varchar(32) NOT NULL COMMENT '客户标识', + customer_name varchar(255) DEFAULT NULL COMMENT '客户名称', + warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识', + warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称', + business_type varchar(32) NOT NULL COMMENT '原入库业务类型', + business_id bigint NOT NULL COMMENT '原入库业务主键', + business_no varchar(128) NOT NULL COMMENT '原入库业务单号', + order_id bigint DEFAULT NULL COMMENT '尾程订单主键', + inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码', + balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键', + batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次', + sku_code varchar(128) DEFAULT NULL COMMENT '商品编码', + rule_code varchar(32) NOT NULL COMMENT '仓租规则', + original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量', + remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量', + original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数', + inbound_at datetime(3) NOT NULL COMMENT '实际入库时间', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_lot_source (source_key), + KEY idx_storage_rent_stock (balance_id,batch_no), + KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次'; + +CREATE TABLE ll_storage_rent_share ( + balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空', + batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用', + id bigint NOT NULL COMMENT '份额主键', + lot_id bigint NOT NULL COMMENT '原计租批次主键', + customer_id varchar(32) NOT NULL COMMENT '客户标识', + warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识', + carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛', + sku_code varchar(128) DEFAULT NULL COMMENT '商品编码', + quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code), + UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no), + KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark), + KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额'; + +CREATE TABLE ll_storage_rent_preparation ( + id bigint NOT NULL COMMENT '预校验主键', + warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓', + customer_id varchar(32) NOT NULL COMMENT '费用客户', + mode varchar(32) NOT NULL COMMENT '扫码类型', + execution_no varchar(128) NOT NULL COMMENT '实际作业单号', + request_key varchar(255) NOT NULL COMMENT '原扫码幂等键', + request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希', + attempt int NOT NULL COMMENT '冻结尝试次数', + business_type varchar(32) NOT NULL COMMENT '本次应收业务类型', + business_id bigint NOT NULL COMMENT '本次应收业务主键', + business_no varchar(128) NOT NULL COMMENT '本次应收业务单号', + status varchar(32) NOT NULL COMMENT '资金与扫码状态', + plan_json json NOT NULL COMMENT '原批次计费不可变快照', + original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照', + amount decimal(24,12) NOT NULL COMMENT '人民币结算金额', + exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空', + exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份', + freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件', + settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件', + release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件', + release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发', + wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计', + hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键', + ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水', + error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果', + source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键', + created_at datetime(3) NOT NULL COMMENT '预校验时间', + consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间', + updated_at datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt), + KEY idx_storage_rent_execution (mode,execution_no,status), + KEY idx_storage_rent_business (customer_id,business_no), + KEY idx_storage_rent_recovery (status,updated_at), + KEY idx_storage_rent_release (release_status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 238 AS initialized_tables; diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 5013adedc..4cde3e21b 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -23,7 +23,7 @@ | 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` | | 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` | | 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM | -| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 | +| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放;原消费关联退款契约见 `src/main/resources/openapi/finance-wallet-refund-v1.yaml`,第三方支付回调尚未接入 | | 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` | | 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 | | 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` | diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java index c026b5321..2264a4b74 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceWalletController.java @@ -7,11 +7,16 @@ import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest; import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest; import com.baiyue.finance.service.FinanceWalletService; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/finance/v1") @@ -62,4 +67,25 @@ public class PlatformFinanceWalletController { @GetMapping("/recharge-requests/{requestId}/voucher") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") public ResponseEntity voucher(@PathVariable long requestId) { return service.voucher(requestId); } + + /** 退款必须由平台财务针对原消费发起,复用资金服务的账户锁及累计可退上限。 */ + @PostMapping("/accounts/{customerId}/ledger/{ledgerId}/refunds") + @PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')") + public ApiResponse refund(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long customerId, @PathVariable long ledgerId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestBody WalletRefundRequest request) { + if (user == null || user.userId() == null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台财务可操作退款"); + } + if (request == null || idempotencyKey == null || idempotencyKey.isBlank() + || idempotencyKey.trim().length() > 128) throw new IllegalArgumentException("退款请求及幂等键必填且幂等键不超过128字符"); + if (request.reason() == null || request.reason().isBlank() || request.reason().length() > 500) + throw new IllegalArgumentException("退款原因必填且不超过500字符"); + // 技术退款引用由客户和幂等键确定;资金流水号仍由正式 AC 编号生成器分配。 + String refundNo = "MANUAL_REFUND:" + UUID.nameUUIDFromBytes( + (customerId + ":" + idempotencyKey.trim()).getBytes(StandardCharsets.UTF_8)); + return FinanceResponses.success(service.refundConsumption(customerId, ledgerId, refundNo, + idempotencyKey.trim(), request.amount(), user.userId(), user.displayName(), request.reason().trim())); + } } diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java index db7893993..08ec295fe 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java @@ -54,6 +54,9 @@ public final class FinanceWalletDtos { public record WalletHoldActionRequest(String remark) { } + /** 关联原消费退款,原因必填,原流水保持不可变。 */ + public record WalletRefundRequest(BigDecimal amount, String reason) { } + public record WalletHold(long holdId, long customerId, String businessType, String businessNo, BigDecimal amount, BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount, diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 1312e6dc0..9796e4820 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -135,6 +135,7 @@ public class BusinessFeeSourceEventService { positive(value.walletSettlement().holdId(), "walletSettlement.holdId"); positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); + walletIdentity(value); } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); } @@ -154,7 +155,7 @@ public class BusinessFeeSourceEventService { throw error("FIN-EVT-012", "应付来源供应商快照不一致"); validateSettlement(value); } else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory()) - || !blank(value.settlementMode()) || value.paidAt() != null + || value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null || !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo()) || (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) { throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实"); @@ -202,9 +203,10 @@ public class BusinessFeeSourceEventService { long customerId = positive(source.customerId(), "customerId"); long holdId = positive(reference.holdId(), "holdId"); long ledgerId = positive(reference.ledgerId(), "ledgerId"); + String[] identity = walletIdentity(source); List> rows = jdbc.queryForList( "SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE", - reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo()); + reference.commandEventId(), holdId, ledgerId, customerId, identity[0], identity[1]); if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount) || amount.negate().compareTo(source.totalAmount()) != 0) { throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配"); @@ -218,8 +220,8 @@ public class BusinessFeeSourceEventService { || !reference.holdId().equals(result.path("walletHoldId").asText()) || !reference.ledgerId().equals(result.path("walletLedgerId").asText()) || result.path("customerId").asLong() != customerId - || !source.businessType().equals(result.path("businessType").asText()) - || !source.businessNo().equals(result.path("businessNo").asText()) + || !identity[0].equals(result.path("businessType").asText()) + || !identity[1].equals(result.path("businessNo").asText()) || result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) { throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配"); } @@ -234,6 +236,25 @@ public class BusinessFeeSourceEventService { feeSourceId); } + /** 独立锁款仅接收已约定的人工费用或仓租身份,不能借此挂接任意业务扣款。 */ + private String[] walletIdentity(BusinessFeeSourceChangedV1 source) { + var reference = source.walletSettlement(); + String type = reference.walletBusinessType(), no = reference.walletBusinessNo(); + if (type == null && no == null) return new String[]{source.businessType(), source.businessNo()}; + if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem())) + throw error("FIN-EVT-017", "资金业务引用不完整"); + if ("OVERSEAS_MANUAL_FEE".equals(type)) { + if ("FEE_TYPE_077".equals(source.feeItemCode()) + || !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*")) + throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配"); + } else if ("STORAGE_RENT".equals(type)) { + if (!"FEE_TYPE_077".equals(source.feeItemCode())) + throw error("FIN-EVT-017", "仓租资金引用与费用项目不匹配"); + positive(no, "walletBusinessNo"); + } else throw error("FIN-EVT-017", "不支持的独立资金业务引用"); + return new String[]{type, no}; + } + private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) { jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)", positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(), diff --git a/finance/src/main/resources/openapi/finance-wallet-refund-v1.yaml b/finance/src/main/resources/openapi/finance-wallet-refund-v1.yaml new file mode 100644 index 000000000..ec75a8eb3 --- /dev/null +++ b/finance/src/main/resources/openapi/finance-wallet-refund-v1.yaml @@ -0,0 +1,61 @@ +openapi: 3.0.3 +info: + title: 平台资金账户关联退款 + version: 1.0.0 + description: 原消费保持不可变;退款记录关联原消费流水号,累计退款不得超过原消费金额,包含业务退款事件已退金额。 +paths: + /api/platform/finance/v1/accounts/{customerId}/ledger/{ledgerId}/refunds: + post: + operationId: refundWalletConsumption + summary: 原消费关联退款 + description: 仅平台 tenantId=0 且具有 platform:finance:customer-account:adjust 权限可操作。账户行锁串行校验累计可退金额,无需前端提交账户版本。优先归还已用授信,再增加实际余额。相同键同请求返回原流水;相同键不同原消费或金额失败。非成功消费、跨客户原流水、超额退款均拒绝。 + security: + - bearerAuth: [] + parameters: + - {name: customerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: ledgerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [amount, reason] + properties: + amount: {type: number, minimum: 0.01, maximum: 999999999999.99, multipleOf: 0.01} + reason: {type: string, minLength: 1, maxLength: 500, description: 必填退款原因,不得仅空白} + responses: + '200': + description: 统一响应 code=200 成功;业务失败使用 FIN-REQ-400、FIN-WALLET-404、FIN-IDEMPOTENCY-CONFLICT 或 FIN-WALLET-REFUND-EXCEEDED 等业务码。 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + $ref: '#/components/schemas/RefundLedgerEntry' + '403': {description: 非平台主体或缺少调账权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + RefundLedgerEntry: + type: object + description: 完整返回 FinanceWalletDtos.WalletLedgerEntry,资金余额字段沿用账户流水协议。 + properties: + ledgerId: {type: string} + businessNo: {type: string, description: 新退款流水正式 AC 单号} + changeType: {type: string, enum: [REFUND]} + amount: {type: number, description: 实际退款正金额} + sourceType: {type: string, enum: [BUSINESS_REFUND]} + sourceNo: {type: string, description: 客户及幂等键派生的稳定技术退款引用} + referenceNo: {type: string, description: 原消费流水 businessNo,用于关联及累计可退校验} + operatorName: {type: string, nullable: true} + remark: {type: string, description: 退款原因} + actualBalanceAfter: {type: number} + frozenBalanceAfter: {type: number} + availableBalanceAfter: {type: number} + creditUsedAfter: {type: number} + availableCreditAfter: {type: number} diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index 3d69bc90a..b99700bb0 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -286,4 +286,82 @@ class BusinessFeeSourceEventServiceTest { base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } + + @Test + void manualConsumptionUsesDedicatedFundingIdentityWhilePreservingServiceOrder() { + verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071"); + } + + @Test + void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() { + verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077"); + } + + @Test + void dedicatedFundingCannotReferenceAnotherManualFeeOrWrongFeeItem() { + assertThatThrownBy(() -> service.consume(dedicatedEvent("OVERSEAS_MANUAL_FEE", "702-1", "FEE_TYPE_071"))) + .hasMessageContaining("来源不匹配"); + assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_071"))) + .hasMessageContaining("项目不匹配"); + noFinancialWrites(); + } + + @Test + void dedicatedConsumptionCannotReuseHoldAlreadyLinkedToAnotherSource() { + stubDedicatedConsumption("STORAGE_RENT", "445"); + when(jdbc.queryForList(startsWith("SELECT id FROM fin_fee_source WHERE wallet_hold_id="), + eq(Long.class), eq(9L), eq(901L))).thenReturn(List.of(902L)); + assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077"))) + .hasMessageContaining("已关联其他费用"); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class)); + } + + @Test + void dedicatedConsumptionRejectsResultFromDifferentFundingBusiness() { + stubDedicatedConsumption("STORAGE_RENT", "445"); + when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L), + eq(10001L), eq("STORAGE_RENT"), eq("445"))).thenReturn(List.of(Map.of( + "amount", new BigDecimal("-120.00"), "result_json", """ + {"result":"SUCCEEDED","operation":"SETTLE","commandEventId":"command-1", + "walletHoldId":"9","walletLedgerId":"72","customerId":10001, + "businessType":"STORAGE_RENT","businessNo":"446","amount":120.00} + """))); + assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077"))) + .hasMessageContaining("资金结果不匹配"); + } + + private void verifyDedicatedConsumption(String type, String no, String item) { + stubDedicatedConsumption(type, no); + EventEnvelope event = dedicatedEvent(type, no, item); + service.consume(event); + verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L)); + assertThat(event.payload().path("businessType").asText()).isEqualTo("WAREHOUSE_SERVICE"); + assertThat(event.payload().path("businessNo").asText()).isEqualTo("PO-US-001"); + } + + private void stubDedicatedConsumption(String type, String no) { + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L); + ObjectNode result = json.createObjectNode(); + result.put("result", "SUCCEEDED").put("operation", "SETTLE").put("commandEventId", "command-1") + .put("walletHoldId", "9").put("walletLedgerId", "72").put("customerId", 10001) + .put("businessType", type).put("businessNo", no).put("amount", new BigDecimal("120.00")); + when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L), + eq(10001L), eq(type), eq(no))).thenReturn(List.of(Map.of( + "amount", new BigDecimal("-120.00"), "result_json", result.toString()))); + } + + private EventEnvelope dedicatedEvent(String type, String no, String item) { + EventEnvelope base = eventWithoutInbound(); + ObjectNode payload = base.payload().deepCopy(); + payload.put("feeItemCode", item); + payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9") + .put("ledgerId", "72").put("walletBusinessType", type).put("walletBusinessNo", no); + payload.putNull("action"); payload.putNull("sourceHash"); + payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + payload.put("action", "PUBLISHED"); + return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(), + base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } } diff --git a/finance/src/test/java/com/baiyue/finance/service/OverseasFeeWalletClosureTest.java b/finance/src/test/java/com/baiyue/finance/service/OverseasFeeWalletClosureTest.java new file mode 100644 index 000000000..b6e51e4e6 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/OverseasFeeWalletClosureTest.java @@ -0,0 +1,183 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.dto.FinanceWalletDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 真实资金服务、隔离数据库及事务验证海外费用释放、实扣与重复操作。 */ +class OverseasFeeWalletClosureTest { + private JdbcTemplate jdbc; + private FinanceWalletService wallet; + private TransactionTemplate transaction; + + /** 复用正式资金表定义,所有造数只进入本测试独立内存库。 */ + @BeforeEach void setup() throws Exception { + var dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(dataSource); + transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + var sql = Files.readString(Path.of("src/main/resources/db/finance.sql")); + for (var table : List.of("fin_wallet_account", "fin_wallet_hold", "fin_wallet_ledger", "fin_idempotency_record", "fin_audit_log")) { + var matcher = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?\\) ENGINE=[^;]+;").matcher(sql); + assertThat(matcher.find()).as(table).isTrue(); + // H2 使用同一列及约束,但去除 MySQL 引擎与更新时间自动生成,避免多列生成键差异。 + var ddl = matcher.group().replaceAll("(?s) ENGINE=.*", "") + .replaceAll("(?i) UNSIGNED", "") + .replaceAll("(?i) ON UPDATE CURRENT_TIMESTAMP\\(3\\)", "") + .replaceAll("(?i) DEFAULT CURRENT_TIMESTAMP\\(3\\)", "") + .replaceAll("(?i)((?:created_at|updated_at) DATETIME\\(3\\)) NOT NULL", "$1 NULL"); + jdbc.execute(ddl); + } + var numbers = mock(FinanceDocumentNumberGenerator.class); + var sequence = new AtomicInteger(); + when(numbers.next(anyString())).thenAnswer(call -> "AC-TEST-" + sequence.incrementAndGet()); + wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(), + mock(FinanceFileClient.class), numbers, "test", "", "test", "test"); + jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(9,'CNY',60,0,160,100,0,0,?,0,0,0)", LocalDate.now().plusDays(7)); + } + + /** 驳回后释放原冻结,实际余额和授信均恢复可用且不发生消费。 */ + @Test void rejectionReleasesCashAndCreditExactlyOnce() { + freeze(); + assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("80.00"); + assertThat(wallet.account(9).creditFrozen()).isEqualByComparingTo("20.00"); + var request = new WalletHoldActionRequest("金额录错,驳回费用"); + transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request)); + transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request)); + var account = wallet.account(9); + assertThat(account.actualBalance()).isEqualByComparingTo("60.00"); + assertThat(account.frozenBalance()).isZero(); assertThat(account.creditFrozen()).isZero(); + assertThat(account.availableBalance()).isEqualByComparingTo("160.00"); + assertThat(count("CONSUMPTION")).isZero(); assertThat(count("UNFREEZE")).isEqualTo(1); + } + + /** 扣款事务失败后可重试原请求,多次点击或不同请求键均不重复消费。 */ + @Test void failedSettlementRollsBackAndRetryConsumesOnlyOnce() { + freeze(); + var request = new WalletHoldActionRequest("审核通过扣款"); + assertThatThrownBy(() -> transaction.execute(status -> { + wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request); + throw new IllegalStateException("模拟扣款事务提交前异常"); + })).hasMessageContaining("模拟扣款"); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00"); + assertThat(count("CONSUMPTION")).isZero(); + for (int repeat = 0; repeat < 3; repeat++) + transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request)); + assertThatThrownBy(() -> transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "another-key", request))) + .hasMessageContaining("已处理"); + assertThat(wallet.account(9).actualBalance()).isZero(); + assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00"); + assertThat(wallet.account(9).frozenBalance()).isZero(); assertThat(count("CONSUMPTION")).isEqualTo(1); + } + + /** 第一版错扣退回复用手工充值;原因、关联单号和操作人进入资金流水。 */ + @Test void manualCreditRetainsCorrectionReasonAndReplaysWithoutDoubleCredit() { + var request = new AccountAdjustmentRequest("RECHARGE", new BigDecimal("12.00"), LocalDate.now(), + "OTHER", "TR-001 / 费用701", "退回多收操作费", 0); + var first = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request)); + var second = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request)); + assertThat(second.ledgerId()).isEqualTo(first.ledgerId()); + assertThat(first.remark()).isEqualTo("退回多收操作费"); + assertThat(first.referenceNo()).isEqualTo("TR-001 / 费用701"); + assertThat(first.operatorName()).isEqualTo("财务员"); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("72.00"); + assertThat(count("RECHARGE")).isEqualTo(1); + } + + /** 部分退款优先还授信,累计退款不能超过原消费,原消费事实保持不变。 */ + @Test void linkedRefundRestoresCreditThenCashAndCannotExceedOriginalConsumption() { + freeze(); + var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("审核扣款"))); + long original = hold.actionLedgerId(); + var first = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款")); + var replay = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款")); + assertThat(replay.ledgerId()).isEqualTo(first.ledgerId()); + assertThat(first.changeType()).isEqualTo("REFUND"); + assertThat(first.sourceType()).isEqualTo("BUSINESS_REFUND"); + assertThat(first.referenceNo()).isEqualTo(jdbc.queryForObject("SELECT business_no FROM fin_wallet_ledger WHERE id=?", String.class, original)); + assertThat(wallet.account(9).creditUsed()).isZero(); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("5.00"); + assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", BigDecimal.ONE, 7, "财务", "变更金额"))).hasMessageContaining("幂等"); + assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-too-much", "refund-too-much", new BigDecimal("56"), 7, "财务", "退款"))).hasMessageContaining("超过"); + transaction.execute(status -> wallet.refundConsumption(9, original, "refund-2", "refund-key-2", new BigDecimal("55"), 7, "财务", "余款退款")); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00"); + assertThat(count("REFUND")).isEqualTo(2); + assertThat(count("CONSUMPTION")).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger WHERE id=?", BigDecimal.class, original)).isEqualByComparingTo("-80.00"); + } + + /** 非消费流水、跨客户原流水及非正金额均不能改变余额。 */ + @Test void linkedRefundRejectsWrongCustomerNonConsumptionAndInvalidAmount() { + freeze(); + long frozenLedger = jdbc.queryForObject("SELECT id FROM fin_wallet_ledger WHERE change_type='FREEZE'", Long.class); + assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, frozenLedger, "bad", "bad", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("成功业务扣款"); + var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款"))); + jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(10,'CNY',0,0,0,0,0,0,0,0,0)"); + assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(10, hold.actionLedgerId(), "wrong-customer", "wrong-customer", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("不存在"); + assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "zero", "zero", BigDecimal.ZERO, 7, "财务", "退款"))).isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "blank", "blank", BigDecimal.ONE, 7, "财务", " "))).hasMessageContaining("原因"); + assertThat(count("REFUND")).isZero(); + assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00"); + assertThat(wallet.account(10).actualBalance()).isZero(); + } + + /** 不同请求键的并发退款同样受原消费可退余额约束。 */ + @Test void concurrentRefundsSerializeOnAccountAndNeverOverRefund() throws Exception { + freeze(); + var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款"))); + var start = new java.util.concurrent.CountDownLatch(1); + var executor = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var results = new java.util.ArrayList>(); + for (int i = 0; i < 2; i++) { + final String reference = "concurrent-" + i; + results.add(executor.submit(() -> { + start.await(); + try { + transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), reference, reference, new BigDecimal("50"), 7, "财务", "并发退款")); + return true; + } catch (FinanceEventException ex) { + assertThat(ex.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED"); + return false; + } + })); + } + start.countDown(); + int succeeded = 0; + for (var result : results) if (result.get(10, java.util.concurrent.TimeUnit.SECONDS)) succeeded++; + assertThat(succeeded).isEqualTo(1); + assertThat(count("REFUND")).isEqualTo(1); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("30.00"); + } finally { executor.shutdownNow(); } + } + + /** 冻结始终沿用同一海外费用业务身份。 */ + private void freeze() { + transaction.execute(status -> wallet.freeze(9, "freeze", new WalletFreezeRequest( + new BigDecimal("80.00"), "OVERSEAS_MANUAL_FEE", "701-1", "海外费用"))); + } + + /** 按真实流水类型统计资金影响,避免只检查服务返回状态。 */ + private int count(String type) { + return jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=9 AND change_type=?", Integer.class, type); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFeeCatalogController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFeeCatalogController.java new file mode 100644 index 000000000..6254de465 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalFeeCatalogController.java @@ -0,0 +1,58 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.service.SharedFeeCatalogService; +import org.springframework.beans.factory.annotation.Value; +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; +import java.security.MessageDigest; +import java.util.List; + +/** 公共费用目录内部只读入口,禁止借用平台员工令牌跨服务查询。 */ +@RestController +@RequestMapping("/internal/firstleg/v1/fee-items") +public class InternalFeeCatalogController { + private final SharedFeeCatalogService service; + private final byte[] expectedToken; + + /** 绑定费用目录服务与内部身份凭证,空凭证始终拒绝访问。 */ + public InternalFeeCatalogController(SharedFeeCatalogService service, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.service = service; + this.expectedToken = token.getBytes(StandardCharsets.UTF_8); + } + + /** 返回适用海外仓收付方向的启用项目。 */ + @GetMapping + public ApiResponse> list( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam(defaultValue = "RECEIVABLE") String direction) { + requireToken(token); + return ApiResponse.success(service.list(direction)); + } + + /** 在费用创建时验证实时项目资格。 */ + @GetMapping("/{code}") + public ApiResponse detail( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String code, @RequestParam String direction) { + requireToken(token); + return ApiResponse.success(service.requireActive(code, direction)); + } + + /** 使用常量时间比较内部凭证,未配置凭证时关闭入口。 */ + private void requireToken(String token) { + if (expectedToken.length == 0 || token == null + || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } +} 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 0008d8859..7161acf59 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java @@ -54,6 +54,18 @@ public class FeeItemRepository { .eq(FeeItemEntity::getFeeCode, feeCode)); } + /** 海外录费仅提供平台启用的国际仓库收付费项目,不复制费用目录。 */ + public List listActiveInternationalWarehouseItems(String direction) { + return feeItems.selectList(new LambdaQueryWrapper() + .eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID) + .eq(FeeItemEntity::getStatus, "ACTIVE") + .eq(FeeItemEntity::getTradeScope, "INTERNATIONAL") + .eq("RECEIVABLE".equals(direction), FeeItemEntity::getWarehouseReceivable, true) + .eq("PAYABLE".equals(direction), FeeItemEntity::getWarehousePayable, true) + .orderByAsc(FeeItemEntity::getCategory) + .orderByAsc(FeeItemEntity::getFeeTypeName)); + } + public boolean itemNameExists(String tradeScope, String feeTypeName, Long excludedId) { return feeItems.selectCount(new LambdaQueryWrapper() .eq(FeeItemEntity::getTenantId, PLATFORM_TENANT_ID) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SharedFeeCatalogService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SharedFeeCatalogService.java new file mode 100644 index 000000000..4e0ed7705 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SharedFeeCatalogService.java @@ -0,0 +1,53 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.repository.FeeItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; + +/** 向海外业务提供公共费用目录的只读切片,项目维护仍归头程主数据。 */ +@Service +@RequiredArgsConstructor +public class SharedFeeCatalogService { + private final FeeItemRepository repository; + + /** 按收付方向返回当前可使用的费用项目。 */ + public List list(String direction) { + requireDirection(direction); + return repository.listActiveInternationalWarehouseItems(direction).stream() + .map(SharedFeeCatalogService::snapshot).toList(); + } + + /** 写入业务费用前重新验证项目资格,停用或方向变化不得沿用旧选项。 */ + public Item requireActive(String code, String direction) { + requireDirection(direction); + FeeItemEntity item = repository.findItemByCode(code); + if (item == null || !"ACTIVE".equals(item.getStatus()) + || !"INTERNATIONAL".equals(item.getTradeScope()) + || !("RECEIVABLE".equals(direction) + ? Boolean.TRUE.equals(item.getWarehouseReceivable()) + : Boolean.TRUE.equals(item.getWarehousePayable()))) { + throw new BizException(ResultCode.CONFLICT, "费用项目未启用或不适用于本次收费"); + } + return snapshot(item); + } + + /** 校验显式收付方向,防止空方向绕过目录过滤。 */ + private static void requireDirection(String direction) { + if (!"RECEIVABLE".equals(direction) && !"PAYABLE".equals(direction)) { + throw new BizException(ResultCode.BAD_REQUEST, "请选择费用收付方向"); + } + } + + /** 返回有限字段,不向其他服务暴露目录实体或审计属性。 */ + private static Item snapshot(FeeItemEntity item) { + return new Item(item.getFeeCode(), item.getFeeTypeName(), item.getDefaultPrice()); + } + + public record Item(String code, String name, BigDecimal defaultPrice) { } +} diff --git a/firstleg/src/main/resources/openapi/fee-catalog-internal-v1.yaml b/firstleg/src/main/resources/openapi/fee-catalog-internal-v1.yaml new file mode 100644 index 000000000..145be76ea --- /dev/null +++ b/firstleg/src/main/resources/openapi/fee-catalog-internal-v1.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: { title: 公共费用目录内部查询, version: 1.0.0 } +security: [{ internalToken: [] }] +paths: + /internal/firstleg/v1/fee-items: + get: + summary: 查询当前启用的国际海外仓费用项目 + parameters: + - { in: query, name: direction, schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE } } + responses: + '200': { description: '统一响应data为费用项目数组,项目含code、name、defaultPrice;defaultPrice币种固定CNY,可为空。' } + '403': { description: 内部凭证缺失、错误或服务未配置凭证 } + /internal/firstleg/v1/fee-items/{code}: + get: + summary: 校验录费使用的当前费用项目资格 + parameters: + - { in: path, name: code, required: true, schema: { type: string } } + - { in: query, name: direction, required: true, schema: { type: string, enum: [RECEIVABLE, PAYABLE] } } + responses: + '200': { description: 统一响应data为项目code、name、defaultPrice } + '409': { description: 项目不存在、停用或不适用于所选方向 } +components: + securitySchemes: + internalToken: { type: apiKey, in: header, name: X-Internal-Service-Token } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SharedFeeCatalogServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SharedFeeCatalogServiceTest.java new file mode 100644 index 000000000..f340333ad --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SharedFeeCatalogServiceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.controller.InternalFeeCatalogController; +import com.baiyue.firstleg.entity.FeeItemEntity; +import com.baiyue.firstleg.repository.FeeItemRepository; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SharedFeeCatalogServiceTest { + /** 已停用、方向变更或非海外项目不能沿用页面旧选项。 */ + @Test void currentEligibilityIsCheckedBeforeUse() { + FeeItemRepository repository = mock(FeeItemRepository.class); + SharedFeeCatalogService service = new SharedFeeCatalogService(repository); + FeeItemEntity item = new FeeItemEntity(); + item.setFeeCode("FEE-1"); item.setFeeTypeName("操作费"); + item.setStatus("ACTIVE"); item.setTradeScope("INTERNATIONAL"); + item.setWarehouseReceivable(true); item.setDefaultPrice(new BigDecimal("2.50")); + when(repository.findItemByCode("FEE-1")).thenReturn(item); + when(repository.listActiveInternationalWarehouseItems("RECEIVABLE")).thenReturn(List.of(item)); + assertEquals(new BigDecimal("2.50"), service.list("RECEIVABLE").get(0).defaultPrice()); + assertEquals("操作费", service.requireActive("FEE-1", "RECEIVABLE").name()); + assertThrows(BizException.class, () -> service.requireActive("FEE-1", "PAYABLE")); + item.setStatus("INACTIVE"); + assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE")); + item.setStatus("ACTIVE"); item.setTradeScope("DOMESTIC"); + assertThrows(BizException.class, () -> service.requireActive("FEE-1", "RECEIVABLE")); + assertThrows(BizException.class, () -> service.list(null)); + } + + /** 内部入口未配置、缺少或错误凭证均不得读取目录。 */ + @Test void internalCatalogRequiresExplicitServiceIdentity() { + SharedFeeCatalogService service = mock(SharedFeeCatalogService.class); + InternalFeeCatalogController controller = new InternalFeeCatalogController(service, "configured-token"); + assertThrows(BizException.class, () -> controller.list(null, "RECEIVABLE")); + assertThrows(BizException.class, () -> controller.detail("wrong", "FEE-1", "RECEIVABLE")); + assertThrows(BizException.class, () -> new InternalFeeCatalogController(service, "").list("", "RECEIVABLE")); + verifyNoInteractions(service); + when(service.list("RECEIVABLE")).thenReturn(List.of()); + assertTrue(controller.list("configured-token", "RECEIVABLE").successful()); + } +} diff --git a/iam/src/main/resources/db/overseas-fee-permissions.sql b/iam/src/main/resources/db/overseas-fee-permissions.sql new file mode 100644 index 000000000..79d7184d5 --- /dev/null +++ b/iam/src/main/resources/db/overseas-fee-permissions.sql @@ -0,0 +1,50 @@ +-- 海外费用查询菜单;编辑与审核沿用独立财务按钮权限,只读授权不包含写操作。 +USE baiyue_iam; +SET NAMES utf8mb4; +DROP PROCEDURE IF EXISTS check_overseas_fee_permission_identity; +DELIMITER $$ +CREATE PROCEDURE check_overseas_fee_permission_identity() +BEGIN + -- 先恢复既有财务写按钮和管理员授权,再部署菜单;列表权限不代表可创建或审核。 + -- 缺失时先执行 deploy/sql/overseas-fee-canonical-write-permissions-repair.sql。 + IF (SELECT COUNT(*) FROM iam_role WHERE id=4000001 AND tenant_id=0 AND app_code='PLATFORM' + AND role_code='PLATFORM_ADMIN' AND role_type='SYSTEM' AND status='ACTIVE' AND deleted=0)<>1 + OR EXISTS(SELECT 1 FROM iam_role WHERE role_code='PLATFORM_ADMIN' AND id<>4000001) + OR (SELECT COUNT(*) FROM iam_permission p JOIN iam_role_permission rp ON rp.permission_id=p.id + WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_type='BUTTON' AND p.status='ACTIVE' AND p.deleted=0 + AND ((p.id=5105024 AND p.permission_code='platform:finance:fee-source:review') + OR (p.id=5105025 AND p.permission_code='platform:finance:fee-source:edit')))<>2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Run overseas-fee-canonical-write-permissions-repair.sql first; fee write grants missing'; + END IF; + IF EXISTS(SELECT 1 FROM iam_permission WHERE + (id=5117200 AND (permission_code<>'platform:overseas-fee:list' OR tenant_id<>0 OR app_code<>'PLATFORM' OR permission_type<>'MENU')) + OR (permission_code='platform:overseas-fee:list' AND (id<>5117200 OR tenant_id<>0 OR app_code<>'PLATFORM'))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Overseas fee permission identity conflict'; + END IF; +END$$ +DELIMITER ; +CALL check_overseas_fee_permission_identity(); +DROP PROCEDURE check_overseas_fee_permission_identity; + +START TRANSACTION; +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=0,status='ACTIVE',deleted=0; + +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0; + +-- 默认管理员获得菜单后,同事务推进授权版本,已有会话及时读取新权限。 +INSERT INTO iam_security_subject_version(auth_user_id,permission_version) +SELECT DISTINCT u.auth_user_id,1 FROM iam_user u +JOIN iam_membership m ON m.user_id=u.id +JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id +JOIN iam_role r ON r.id=mr.role_id +WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0 + AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='PLATFORM' + AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0 +ON DUPLICATE KEY UPDATE permission_version=permission_version+1; +COMMIT; diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index e576142c9..6840e75c4 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -57,8 +57,9 @@ | 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | -| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | -| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;当前 V1 审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。阶段 0A—0E 开发目标另读 `../../docs/财务管理/海外仓费用统一接入阶段0技术契约.md`,收费身份、候选版本、Finance 结果事件与资金计划 V2 尚须以实际代码落地验证,不能因文档存在宣称已实现 | +| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 | +| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 | +| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,部分出库只承担原批次份额,余货不重置库龄。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存,按真实出库部分生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | @@ -161,3 +162,9 @@ - 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 - 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 - 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 + +### 海外人工收费首版切换 + +- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。 +- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购保留独立收费流程,禁止进入人工通用收费。 +- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。 diff --git a/lastleg-service/docs/overseas-fees-storage-rent-release.md b/lastleg-service/docs/overseas-fees-storage-rent-release.md new file mode 100644 index 000000000..32c1a4759 --- /dev/null +++ b/lastleg-service/docs/overseas-fees-storage-rent-release.md @@ -0,0 +1,167 @@ +# 海外费用与仓租首版发布验收 + +本文件记录本次实现范围及验收口径。源码、本地测试、模拟页面预览、环境部署与实际资金验收分别记录;本地结构补充与重启见文末记录,不能据此认定上线验收通过。 + +## 首版规则 + +费用入口为 Admin「海外业务 → 海外费用管理」,仅保留「费用明细」与「仓租库存」两个页签。仓租库存查看相关单据、入库批次、库龄、剩余数量及出库数量记录,不展示金额和审核、调价入口。仓租金额及入库/出库时间、出库批次、计算金额、应扣及实扣金额在费用明细详情查询,多入库批次分别展示实际入库时间。审核与调价统一在对应尾程单据详情的「费用明细」办理,审核前调价必须填写说明。复用 Firstleg 公共费用项目,仓租项目为 `FEE_TYPE_077 / 仓储费`;不建立第二套海外费用目录。人工费用选择真实客户、当前服务业务单、项目、金额与币种后创建,详情保留必要的审核和资金状态。 + +头程附加费审核后推送 Finance 账单。其余海外人工应收创建时冻结余额及可用授信,审核通过后扣款,Finance 成功扣款回执后登记本地已收费用,直接显示已完成,不再推送财务账单;驳回、取消释放冻结。在途回执、失败重试与同键重放不能重复扣款。车辆或同车运输不能代替每笔费用对应的客户和业务订单。 + +现有尾程订单没有权威的“原报价已含派送费”字段。首版新增头程派送费仍由审核人员核对原报价,已包含则驳回该费用;系统尚不支持自动判断报价包含关系,审核提示不能替代人工核对。 + +头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始、实际 PDA 出库停止本次出库份额计租,每24小时计一天,不足24小时按一天。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。 + +头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。 + +| 规则 | 计费阶梯(USD) | 数量口径 | +|---|---|---| +| 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 | +| 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | 原始托数向上取整,按本次出库占原托比例分摊 | + +自动计租优先读取现有客户仓租配置;无客户配置时使用上述两张报价表。USD 配置缺少整组中转或退货阶梯时,缺失组补用对应默认报价;非 USD 配置不得直接补用美元价格。已配置阶梯须从第1天连续覆盖至无限期,有限尾阶梯、缺段、负价或不支持的币种均阻断计价,不将配置错误或服务异常当作默认报价。 + +每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。后续新预占按最新客户配置计算本次出库份额的全部库龄,仍保留最初入库时间;本版不把一次出库按历次调价日期再拆段计费。过期或货物、计租天数等依据变化时须重新预校验。 + +原币金额与人民币结算额分别保留,冻结时固定汇率快照。无有效汇率时不得将美元直接作为人民币扣款。免费期零仓租保留出库记录,不要求汇率、不冻结资金。仓租自动计算,出库仅登记待审核费用并保留冻结;批准后沿原资金身份按实额扣款,驳回释放原冻结。订单费用和海外费用管理均展示原批次实际入库、实际出库、原计算金额、应扣金额及实扣金额。审核前允许原币金额调整且说明必填,保存版本;调低释放多余占用,调高由Finance结算时原子检查差额,余额不足保留原冻结,不登记实扣。原零元改非零先补冻结,成功后才能审核。其余客户收费人工录入并审核。历史已扣费用保留结果,不补审、不重复扣款。 + +新计租批次在实际入库事务中记录 UTC 时刻,接口携带时区,页面按浏览器时区显示;不猜测历史无时区时间并回填。退货 SKU 换标保存原批次份额迁移快照,撤销时按该快照恢复。批次全部出库后,库龄固定在最后实际出库时刻。 + +## 计租验收样例 + +1. 中转批次10箱共2CBM,第10天出4箱:3个收费日 × 0.8CBM × 0.8 = **1.92 USD**;余6箱第31天出库:(23×0.8+1×1) × 1.2CBM = **23.28 USD**。余货仍从最初入库计龄。 +2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5×40%=**6 USD**;第40天出余60件:(30×1.5+10×2)×60%=**39 USD**。第二次出库不再次将剩余货物进位到1托。 +3. 入库后恰好24小时为1天,24小时加1毫秒为2天;恰好7天仍免费,7天加1毫秒进入第8天。对金额先按份额精算,完整收费命令汇总后按结算精度取整。 + +## 发布配套 + +- 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。 +- 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。 +- 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。 +- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql) 仅解决自动仓租双零金额和空汇率入费用源表,保留人工正额与正常费用有效汇率约束,不更新业务数据。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。 +- 新空库:Lastleg 源基线与海外统一初始化脚本同步新表和字段;国内统一基线同步菜单。菜单查询权限 `platform:overseas-fee:list`,编辑与审核分别沿用 `platform:finance:fee-source:edit/review`,仅有查询权限不能写费用。 +- 配置:核对 Firstleg、Warehouse 内部服务地址及内部令牌、客户仓租阶梯、有效 USD/CNY 月度汇率、Finance 资金与费用事件直投及回执配置。当前结算支持 USD、CNY,CNY 按1:1结算,其他币种必须先调整为受支持币种。不得在材料中记录密钥。 +- 发布顺序:先发布 Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。 + +## 实际环境验收 + +以下必须在匹配版本、指定测试客户和仓库上实际操作并保留单号、资金事件、流水和页面证据;本地测试不替代这些步骤。 + +| 场景 | 通过标准 | +|---|---| +| 权限与查询 | 菜单、客户、业务单、项目、审核及资金状态筛选有效;无权限按钮不展示,直接调用同样被拒绝 | +| 人工海外收费 | 创建只冻结;审核后恰好扣一次;驳回/取消释放;不足余额但授信足够可成功,二者合计不足明确失败 | +| 头程附加费 | 审核前不入账单,批准后回到正确头程来源且不扣钱包;人工核对原报价,已含派送费的新增收费须驳回 | +| 仓租原生入库 | 头程自有仓、外来转运和退货实际扫码均生成正确原批次;非计租范围不误收 | +| 部分及分批出库 | 原托比例、阶梯、库龄和余量符合上例;整托/多箱/混SKU全部来源核对后才能扣库存 | +| 客户价格与默认报价 | 客户专价优先;无配置按两张默认报价;接口超时或无效阶梯不允许默认为免费或默认报价 | +| 调价与币种 | 有效预占及已生成费用不受调价影响,新预占使用新版本;USD、CNY 原币及分币种汇总准确,不将混合币种直接相加 | +| 资金门禁 | 未冻结成功、资金不足、过期、未知网络结果均不放行库存写;成功后仅本次实际出库份额扣款 | +| 幂等与并发 | 同键重放、回执重复/错序、双设备争抢不重复扣款或超出原批次数量;内容改变不得复用原键 | +| 移库换标及异常 | 移库、拆合托、换标保持原库龄;未知历史计租来源不可凭空补零放行 | +| 弃置及取消 | 废弃实际扣库存同样校验仓租;放弃未完成的预占能释放,释放失败保留可处理记录 | +| 资金核对 | 原币、汇率、人民币冻结与实扣金额一致,已收费用关联到正确Finance流水;余额应收不推送账单结算池,重试和回执重放不再次扣款 | +| 页面及设备 | 实际浏览器核对表格、筛选、详情、审核弹窗及错误提示;PDA真实扫码和断网恢复通过 | + +## 验证记录 + +本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调与 PDA 设备验收尚未执行。上线前逐项填写环境、版本、执行时间与证据,未通过项不得标成通过。 + +2026-10-22 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果): + +| 范围 | 结果与边界 | +|---|---| +| Finance 与事件契约 | Finance 294项:293通过、1项因外部产品契约仓库缺失跳过;公共事件契约37项通过,包含资金业务引用序列化与旧哈希形状验证 | +| Firstleg 费用目录 | 公共费用项目查询2项通过 | +| Lastleg 受影响链路 | 37个测试类汇总578项:576通过、2项MySQL专用场景因未配置数据库跳过,0失败/错误;覆盖计租、资金回执、真实库存门禁、部分出库、移库换标、退货、转运及原收费入口。订单列表测试与重复注解修正后,受影响两类87项复跑通过 | +| 海外 PDA | 仓租预校验、出库生命周期、履约与排车共88项通过;未运行真实设备 | +| Admin | 新页面与接口定向测试、类型检查及构建通过;最终“头程入仓”文案修正后,展示规则3项定向复跑通过,差异格式检查通过。截图使用模拟数据与真实页面组件,不是环境验收证据 | +| 数据库静态检查 | 初始化及统一SQL合同29项通过;迁移登记检查通过;没有连接数据库或执行SQL | +| 架构与入口 | Mapper扫描424项、Lastleg依赖合同15项、Gateway路由合同5项通过;三个仓库差异格式检查通过 | + +已知环境检查缺口:`agents-index-contract.ps1` 因现有外部文档 `D:/project/docs/业务快速索引.md` 缺失未通过;Admin全量密度检查存在既有 `UnifiedOrdersView` 工具栏32px问题。本次新页面密度定向检查通过,不能据此宣称全仓检查全部通过。 + +页面预览保存在 `D:/project/html/artifacts/overseas-fees-acceptance/`,包含费用列表、费用详情、人工费用审核、头程费用审核、仓租列表及仓租详情。 + +2026-10-22 客户仓租配置接入补充验证(不与上一轮测试数量累加): + +| 范围 | 结果与边界 | +|---|---| +| Warehouse 客户计租接口 | 4个定向测试类共24项全部通过;覆盖客户专价、默认报价、缺失组、无效阶梯、内部鉴权及一致读取 | +| Lastleg 客户计价与出库 | 10个定向测试类共153项全部通过,无失败、错误或跳过;覆盖配置校验、动态阶梯、原批次份额、原价格快照消费、重建预占读取新价、CNY结算、混币展示和整托汇总,以及普通出库、退货出库和废弃库存门禁;生产与测试源码编译通过 | +| Admin 币种展示 | 3个定向测试文件共9项通过,类型检查通过;列表按原币分别显示,详情按收费实际币种显示,不默认所有仓租为USD | +| 静态契约 | Warehouse及仓租OpenAPI可解析;Gateway路由5项、Lastleg依赖15项通过;差异格式检查通过 | + +本次接入没有增加数据库字段;首版数据库定义以“发布配套”列出的国内、海外统一空库基线为准,已有库升级须单独核对。补充验证没有执行数据库脚本、部署、真实HTTP或PDA设备验收。 + +旧收费切换:新换标报价不再冻结,外接运费只保留约定记录,拆柜打托只保留成本分摊。旧派送请求含客户应收将明确提示转到海外费用登记。历史换标若已发布过应收又已有冻结,结清回执只为原来源补扣款引用,绝不生成第二笔;此前进入 Finance 的旧账单不能仅靠本次发布自动回写,须在上线前核对并撤销未结应收或做既有账单核销,避免再次催收。新增旧冻结结清只在实际扣款成功后携带 command/hold/ledger 引用发布。采购独立流程及自动仓租保留。 + +2026-10-22 费用入口与回显修复:尾程详情录费改用公共费用目录,按应收/应付方向查询,禁止自由输入和手工添加自动仓租。目录失败可重试,未加载成功不允许保存;供应商只在应付时加载。订单费用返回并显示统一结算方式及锁款/实扣状态;统一业务类型 LASTLEG_ORDER 展示为“尾程订单”,不把转运、退货尾程单一律标成头程入仓。历史已发布账单保留原结算记录,不自动重扣。 + +客户名称为空时按真实客户 ID 经 IAM 内部接口补齐查询展示,保留原有名称快照,不回写历史费用;费用筛选和业务候选同步补齐名称。新录费用仍按实际 FIRST_LEG 与海外业务类型分流,前者审核入账单、后者锁款后审核扣费。 + +本轮验证:Lastleg 定向4类64项通过,客户查名6项通过;Admin费用详情、接口与管理列表定向测试通过,类型检查通过;Gateway路由5项、Lastleg依赖15项、差异格式检查通过。本机 Lastleg、Warehouse、Finance 已重启加载新编译版本,正式 Gateway 已核验客户名称回显、费用目录方向及仓租查询。完整业务造单、PDA出库、资金实扣的逐单结果另记本轮场景报告,以上结果不能代替全链路验收。 + +2026-10-23 库龄批次与操作入口调整: + +- 头程同单同仓补入库复用首次计租批次,并登记收货幂等记录;其他业务继续按实际入库批次分别计龄,不自动合并历史批次。 +- 页面只保留费用明细、仓租库存;库存查看库龄与余量,费用明细查询计费依据,审核和必填说明的调价放在对应尾程单据详情的费用明细。 +- 自动检查:Admin 费用管理、仓租库存和单据费用组件共35项测试及类型检查通过;Lastleg费用持久化与服务50项、Gateway路由5项通过;此前入库批次相关54项和Mapper扫描425项通过,相关输入未变的结果复用。 +- 本地数据库仅定向补建并核验 `ll_storage_rent_inbound` 表,没有重放空库初始化,没有修改费用或钱包记录。Lastleg与Admin服务已重启,最新检查为后端健康UP、前端HTTP200;最新前端调整由开发服务热更新加载。 +- 本轮未重新执行完整PDA出库及真实资金扣款流程;最终页面布局待人工操作真实页面并截图确认。 + +2026-10-23 来源单展示补充:仓租库存的原业务单号明确标为「来源单号」,出库记录保留「出库业务单号」。单据费用详情及仓租审核弹框按每条原计租批次展示来源类型、来源单号和入库批次;头程货对应原尾程订单,退货货物对应原退货单,换标不覆盖原来源。查询按费用快照引用的批次编号批量读取原批次,并校验客户、仓库一致;缺失来源显示空值,不推断单号,不修改历史费用或计费金额。本次无需数据库结构变更。 + +来源展示自动检查:Admin费用管理、仓租库存、单据费用35项测试与类型检查通过;后端来源隔离及仓租服务37项测试通过,生产和测试源码编译通过。后端已重启加载新增查询字段;页面布局仍待真实页面人工截图确认。 + +2026-10-23 本机开发环境真实 HTTP 费用闭环补充(不含头程,不代表生产发布): + +- 退货换标单 TH202610230002 已完成实物签收、清点、换标和 PDA 出库;出库仓租来源为退货单,原计算 USD 1.50 / CNY 10.01。调价 USD 1.00 必填原因后,旧版本审核被拒,正确版本审核及重放只生成一条 CNY 6.67 消费;释放原冻结 CNY 10.01,归还差额 CNY 3.34。另以原消费流水做 CNY 1.00 部分退款,原消费金额保持不变,退款仅增加一次余额并以 referenceNo 关联原消费。 +- 仓储转运单 ZY202610230002 的自动换标单 TB202610230002 已完成真实箱数清点、入库计龄、换标和 PDA 出库。仓租详情确认来源是 WAREHOUSE_TRANSFER / ZY202610230002,批次 ZY202610230002,数量 1、库龄 1 天;免费 USD 仓租保留 NULL 汇率。原零元改 USD 1.00 并采用实际月汇率 6.67,先冻结 CNY 6.67,审核前不实扣;旧版本审核拒绝,正确审核和重放后仅消费一次 CNY 6.67。原批次余量 0,在库仓租页总数为 0。 +- 自动仓租、本机实际资金流水及数据库金额/Fx边界的 30 项在线断言通过:原零元调价原因/版本门禁、差额冻结/释放、余额实扣、驳回与释放、重复请求、超余额冻结失败、原消费部分退款、来源/库存过滤均有报告。人工费用额外 6 项通过:退货单人工应收 1.23 先冻结后审核只扣一次,转运单 1.11 驳回后解冻,超余额费用不得审核且没有新增冻结或扣款。 +- 本机开发库已按用户单独授权执行 overseas-storage-rent-zero-fee-repair.sql;仅修正指定零元自动仓租金额/汇率约束,保留汇率 DEFAULT 1 但 MyBatis 显式写 NULL,未改业务行。Lastleg 重启加载最终代码。自动化持久化测试 3 项及仓租状态机 42 项通过。 +- Admin 自动化覆盖账户失败重试、500 字退款说明边界及换标资金状态中文映射;聚焦测试和类型检查通过。实际浏览器曾核对退货仓租详情及换标单费用区的审核/调价入口、出入库来源明细;自动仓租审核前“费用状态 —”符合现有契约(仅完成/失败显示状态),旁列显示待审核。 +- 美国卡车 `TR202610230001` 的统一目录应收已在线验证:1.23 元先冻结、审核同键重放只实扣一次,按卡车来源类型/单号查询正确回显;随后原消费关联退款 1.23 元且仅一条退款流水,再按正式取消接口将验收订单收口。卡车链路 7 项在线断言通过。 +- 散板在线验收被本机数据库结构漂移阻断,没有创建散板单。Lastleg 错误为 `ll_local_looseboard_order.transport_mode` 无默认值;开发库结构快照还显示 `pallet_required`、`transport_type` 等旧列与当前实体/基线不一致。当前发布基线和 `LocalLooseboardSchemaContractTest` 均明确排除 `transport_mode`。按授权边界没有为此执行 SQL 或修改业务代码,需先核对并获准将本机 Lastleg 表收敛到当前正式基线,再重试散板链路。 +- UI自动化继续检查时浏览器控制通道超时;此前已读到真实换标单详情 DOM,但本轮缺少最终页面截图,视觉验收状态保留为未完整确认。 +- 在线报告位于 D:/project/.codex-temp/overseas-release-live-report.json、overseas-money-live-report.json、overseas-refund-live-report.json、overseas-manual-money-live-report.json、overseas-zero-adjust-live-report.json;账户凭据和会话不在报告中。 + +2026-10-23 接续验收: + +- 修正 `OverseasManualFeePersistenceTest` 的隔离 H2 费用表夹具,补齐 `exchange_rate` 并将无列名插入改为显式列名;`LocalLooseboardSchemaContractTest`、`LocalLooseboardPersistenceTest`、`OverseasManualFeePersistenceTest` 共14项定向测试通过。 +- 采购与费用跨区离线合同22项通过;Lastleg采购结构、OpenAPI、库存边界、请求边界、费用持久化共38项通过;Finance采购结算、海外钱包收口、派送费用覆盖共26项通过。以上均为本机自动化检查,不等于真实HTTP或页面验收。 +- 浏览器重试仍在读取当前登录页时超时,页面视觉验收继续保留未完成状态。 +- 散板本机结构只读对比已确认:`ll_local_looseboard_order` 仍含旧的 `transport_mode`、`container_type`、`contact_name`、`contact_info`、`requested_date` 及旧日期索引;当前正式基线要求移除这些字段并使用 `product_name`、`estimated_pallet_count`、双联系人、`pickup_at`/`delivery_at` 及新日期索引。未执行数据库变更,待单独授权后再按可审阅增量收敛并重试散板费用链路。 + +当前不能标为海外费用全量上线验收通过:采购专用付款/部分退款及最终交寄门禁、散板单据费用创建和回显、派送费用入口迁移及旧入口不重复计费、应付账期/直付不改变客户钱包、真实角色权限与跨客户伪造归属拒绝,仍需分别针对实际业务单和部署授权做验收;卡车仅完成通用费用闭环,不代表车队履约生命周期已验。自动化测试覆盖了其中部分服务逻辑,但没有替代这些真实 HTTP 场景。本机 Gateway/数据库验证不代表生产部署;双设备竞争、MySQL 锁/回滚、未知网络结果与真实事件错序回执也仍未通过真实环境验收。 + +2026-10-24 接续: + +- 已按单独授权执行本机散板结构收敛:正式表 `ll_local_looseboard_order` 备份为 `backup_ll_local_looseboard_order_20261023`,移除旧字段并补齐当前实体所需可空/默认值及日期索引;正式表与备份表均保留4条原记录。 +- Lastleg 已用完整本地环境变量恢复,`http://127.0.0.1:9303/actuator/health` 返回 UP;散板结构、持久化和人工费用14项定向测试全部通过。 +- 真实消费者造单夹具尚未完成:其测试上下文仍被 Spring Boot/Nacos config import 解析阻断,未据此伪造散板 HTTP 通过结论,也未重复写入已存在的换标、退货或卡车验收数据。 +## 2026-10-24 费用风险回归补充 + +- Lastleg 定向回归 96 项实际执行通过:派送费用入口、人工费用、采购专用资金、费用取消/补偿、Outbox 重试、散板结构和费用归属均无失败。 +- Finance 定向回归 49 项实际执行通过:冻结/实扣/释放事务回滚、重复及冲突回执、Outbox 租约、原消费退款、客户账户隔离均无失败。 +- 新增 `PlatformOverseasFeeSourceControllerSecurityTest`,验证非 `PLATFORM + tenant=0` 主体在读取费用前直接返回 403;1 项通过。 +- `CargoDiscardMysqlAcceptanceTest` 本轮仍因本机未开启 MySQL 验收开关而跳过,不能视为废弃费用真实数据库验收通过。 + +当前仍不能标记为“海外费用全部上线通过”的门槛:采购真实 HTTP 付款/部分退款/交寄门禁、DELIVERY_ORDER 真实费用迁移且旧入口不重复计费、卡车/散板真实单据费用目录闭环、账期/直付应付不触碰客户钱包、真实角色与跨客户 HTTP 隔离、双设备并发/超量出库、MySQL 锁回滚及未知网络回执。这些已有隔离测试或服务层证据,但尚缺真实服务/数据库证据。 + +## 2026-10-26 缺口修复与散板真实接口复验 + +- 本机 Lastleg 按完整 Nacos/JWT/Redis 环境重启,`/actuator/health` 返回 `UP`,并完成 Nacos 注册;此前网关 503 的直接原因是 9303 未启动/未注册,不是散板业务异常。 +- 使用新幂等键 `OVERSEAS-TEST-20261026-loose` 调用真实 Lastleg 接口创建散板订单,返回 `200`,订单号 `LB202610260002`,状态 `PENDING_ACCEPTANCE`。 +- 使用同一幂等键重复创建,返回同一订单 ID 和订单号;真实详情查询返回同一订单及状态,验证散板创建幂等和客户隔离查询。 +- 费用业务候选接口真实返回该散板订单,业务类型 `LOCAL_LOOSEBOARD`、业务单号 `LB202610260002`、客户 ID 一致。 +- Finance 服务当前未在本机注册,网关账户接口仍返回 `503`;因此散板费用冻结、审核、实扣和退款不能冒充真实 HTTP 已通过。Finance 定向自动化 49 项仍全部通过。 + +## 2026-10-26 缺口修复接续 + +- Finance 已使用完整本机 Nacos/JWT/Redis 环境恢复,`9203` 启动并注册;散板费用真实闭环已补验:`LB202610260002` 的登记冻结、审核实扣、详情回显、重复审核只扣一次均通过。 +- 散板原消费已做真实全额退款验收:重复退款复用同一流水、余额只恢复一次、超额退款及同键改金额均拒绝。 +- 真实权限/归属风险已验收:客户令牌访问平台费用和退款均拒绝;伪造客户 ID 或业务单号创建费用均拒绝。 +- 采购真实 HTTP 验收已创建测试采购单并验证下单幂等,但凭证上传因当前本机管理员会话缺少正式 `platform:overseas-business:purchase:buy` 权限被拒绝,不能将采购付款、部分退款、交寄门禁标记为通过。现有权限定义和服务层定向测试仍有效,需补齐本机角色授权后继续。 +- 回归期间发现 `LastlegOrderServiceTest` 使用旧版查询构造参数,已补齐测试调用;Lastleg 采购资金相关四个定向测试(`ProcurementFinancePersistenceTest`、`ProcurementPurchasePersistenceTest`、`ProcurementControlPersistenceTest`、`ProcurementChargePersistenceTest`)及生产/测试编译通过。 +- 当前仍未完成:采购真实付款/部分退款/交寄门禁、派送 `DELIVERY_ORDER` 真实迁移与旧入口去重、应付账期/直付不触碰客户钱包、真实角色矩阵验收、双设备并发/超量出库、MySQL 锁回滚和未知网络回执;页面视觉验收也仍需人工截图确认。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java index 2c77fb221..0bda662ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java @@ -19,6 +19,11 @@ public interface DomesticWarehouseDirectoryClient { ApiResponse receivingWarehouse(@PathVariable("warehouseId") long warehouseId, @RequestHeader("X-Internal-Service-Token") String internalToken); + /** 启用的国内公共仓库,供国内采购收货选择。 */ + @GetMapping("/internal/warehouses/domestic") + ApiResponse> domesticWarehouses(@RequestHeader("X-Internal-Service-Token") String internalToken); + record DomesticWarehouse(Long id, String warehouseCode, String warehouseName) { } + record ReceivingWarehouse(Long warehouseId, String warehouseCode, String warehouseName, String countryCode, String status) { } @GetMapping("/internal/warehouses/0/{warehouseId}/active") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegFeeCatalogClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegFeeCatalogClient.java new file mode 100644 index 000000000..0be4ea461 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegFeeCatalogClient.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +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.util.List; + +/** 费用目录只读契约,不共享头程领域实体。 */ +@FeignClient(name = "firstleg-service", contextId = "firstlegFeeCatalogClient", url = "${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}") +public interface FirstlegFeeCatalogClient { + /** 获取海外仓可选项目。 */ + @GetMapping("/internal/firstleg/v1/fee-items") + ApiResponse> list(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("direction") String direction); + + /** 验证实际录费使用的项目。 */ + @GetMapping("/internal/firstleg/v1/fee-items/{code}") + ApiResponse detail(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable("code") String code, @RequestParam("direction") String direction); + + record Item(String code, String name, BigDecimal defaultPrice) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index f0c033b71..c9084a847 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -91,6 +91,8 @@ import java.util.stream.Collectors; @Service public class DeliveryTransferService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + @Resource private DispatchAggregateRepository dispatches; @Resource private DispatchItemMapper dispatchItems; @Resource private OutboundOrderMapper outboundOrders; @@ -526,6 +528,7 @@ public class DeliveryTransferService { return pickResolved(warehouseId, operatorId, taskNo, request, false); } + /** PDA 真实出库在扣库存前校验同一扫码的仓租冻结,原幂等结果优先恢复。 */ private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo, DeliveryRequests.PdaPick request, boolean palletScan) { OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"), @@ -560,6 +563,9 @@ public class DeliveryTransferService { if (item == null || !customerId.equals(item.getCustomerId()) || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); + storageRent.consume(warehouseId, new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest( + "OUTBOUND", taskNo, request.outboundPalletItemId(), request.inventoryPositionId(), request.packages(), null, null, + request.idempotencyKey(), request.cartonMarks(), request.skuCode(), request.productQuantity())); if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) || ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) { if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan) @@ -729,9 +735,22 @@ public class DeliveryTransferService { return taskResult(outbound, task); } - /** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */ + /** 整托扫描使用与资金预校验完全一致的实际子来源,任一失败则整托回滚。 */ @Transactional public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo, + String palletNo, DeliveryRequests.PdaPalletPick request) { + var children = storageRentPalletRequests(warehouseId, operatorId, taskNo, palletNo, request); + DeliveryResponses.PdaTask result = null; + for (var child : children) result = pickResolved(warehouseId, operatorId, taskNo, child, true); + if (result != null) return result; + var task = pdaTasks.lockByNoAndWarehouse(taskNo, warehouseId); + if (task == null) throw notFound("当前仓没有该出库工单"); + return taskResult(requireOutbound(task), task); + } + + /** 资金预校验和实际整托扫码共享同一来源展开及子扫描幂等键算法。 */ + @Transactional + public java.util.List storageRentPalletRequests(String warehouseId, String operatorId, String taskNo, String palletNo, DeliveryRequests.PdaPalletPick request) { String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); String code = required(taskNo, "PDA task number is required"); @@ -752,7 +771,7 @@ public class DeliveryTransferService { .stream().filter(item -> palletCode.equals(item.getPalletNo())).toList(); if (palletItems.isEmpty()) throw notFound("当前出库任务没有该托盘"); if (palletItems.stream().allMatch(item -> value(item.getPickedPackageCount()) >= value(item.getPackageCount()))) { - return taskResult(outbound, task); + return java.util.List.of(); } if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { @@ -763,14 +782,9 @@ public class DeliveryTransferService { !task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark()) || !"OUTBOUND_PENDING".equals(carton.getStatus()))) throw conflict("托盘内容或出库预留已变化,请刷新核对"); - DeliveryResponses.PdaTask result = taskResult(outbound, task); - for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) { - if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue; - result = pickResolved(boundWarehouseId, operatorId, code, - new DeliveryRequests.PdaPick(item.getId(), null, 1L, - childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())), true); - } - return result; + return palletItems.stream().filter(item -> value(item.getPickedPackageCount()) < value(item.getPackageCount())) + .sorted(Comparator.comparing(OutboundPalletItem::getId)).map(item -> new DeliveryRequests.PdaPick(item.getId(), null, 1L, + childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks()))) .toList(); } List plans = new ArrayList<>(); @@ -797,13 +811,8 @@ public class DeliveryTransferService { if (remaining > 0) throw conflict("托盘可用库存不足,整托拣货未执行:" + palletCode); } - DeliveryResponses.PdaTask result = taskResult(outbound, task); - for (PalletPickPlan plan : plans) { - String operationId = palletPickOperationId(request.idempotencyKey(), code, palletCode, plan); - result = pdaPick(boundWarehouseId, operatorId, code, new DeliveryRequests.PdaPick( - plan.itemId(), plan.positionId(), plan.packages(), operationId, plan.cartonMarks())); - } - return result; + return plans.stream().map(plan -> new DeliveryRequests.PdaPick(plan.itemId(), plan.positionId(), plan.packages(), + palletPickOperationId(request.idempotencyKey(), code, palletCode, plan), plan.cartonMarks())).toList(); } @Transactional @@ -1011,7 +1020,7 @@ public class DeliveryTransferService { delivery.setUpdatedAt(now); cancellations.save(delivery); } OutboundPdaTask task = cancellations.task(outbound); - if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); } + if (task != null) { storageRent.cancelExecution("OUTBOUND",task.getTaskNo()); task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound); for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); } if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java index 9d598d203..e1ab38fe0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java @@ -116,17 +116,13 @@ public class DispatchDeliveryFees { if (fee==null || blank(fee.feeType()) || !SERVICES.contains(fee.feeType()) || blank(fee.direction()) || !Set.of("RECEIVABLE","PAYABLE").contains(fee.direction()) || blank(fee.currency()) || !Set.of("CNY","USD").contains(fee.currency()) || blank(fee.feeName()) || fee.amount()==null || fee.amount().signum()<=0) throw conflict("派送费用类型、方向、币种或金额不合法"); + if ("RECEIVABLE".equals(fee.direction())) throw conflict("请到海外费用登记并审核应收费用"); if (!seen.add(fee.direction()+":"+fee.feeType())) throw conflict("同组同方向同费用不得重复登记"); if ("RECEIVABLE".equals(fee.direction()) && group.cargo().stream().allMatch(c->covered(c,fee.feeType(),evidence,freight))) throw conflict("本组该服务已全部覆盖,不得重复登记应收"); } - var required=new HashSet(); required.add("DELIVERY_FEE"); - if (Set.of("FLOOR","MIXED").contains(input.loadingType())) required.add("LOADING_FEE"); - if ("BUY_APPOINTMENT".equals(input.appointmentAccount())) { - if (blank(input.sellerName())) throw conflict("买约须填写卖家名称");required.add("APPOINTMENT_FEE"); - } - for (var code:required) if (group.cargo().stream().anyMatch(c->!covered(c,code,evidence,freight)) && !seen.contains("RECEIVABLE:"+code)) - throw conflict("本组尚有未覆盖的服务费用,请核对" ); + if ("BUY_APPOINTMENT".equals(input.appointmentAccount()) && blank(input.sellerName())) + throw conflict("买约须填写卖家名称"); } /** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */ @@ -134,7 +130,7 @@ public class DispatchDeliveryFees { fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes()); } - /** 费用与子单同事务保存,只向真实客户目的地发布一次正数来源。 */ + /** 外接报价保留记录;只发布供应商应付,客户应收走人工审核。 */ public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery, DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) { var now=LocalDateTime.now(); @@ -144,12 +140,10 @@ public class DispatchDeliveryFees { operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT"); operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString()); operation.setCreatedAt(now);repository.operation(operation); - if (quote.amount().signum()==0) continue; - fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),quote.bookingNo(),"FREIGHT")), - save(group,delivery,null,"DELIVERY_FEE","外接约定运费", "RECEIVABLE",quote.currency(),quote.amount(), - quote.quantity(),quote.quantityUnit(),quote.unitPrice(),null,quote.inboundCode())); + // 约定运费只保留商业记录,应收由海外费用人工登记审核。 } for (var fee:input.fees()==null?List.of():input.fees()) { + if (!"PAYABLE".equals(fee.direction())) throw conflict("请到海外费用登记并审核应收费用"); var charged=group.cargo().stream().filter(c->!"RECEIVABLE".equals(fee.direction())||!covered(c,fee.feeType(),evidence,freight)).toList(); var orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList(); var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList(); @@ -164,7 +158,7 @@ public class DispatchDeliveryFees { String direction,String currency,BigDecimal amount,BigDecimal quantity,String unit,BigDecimal unitPrice,FeeItem input,String inboundCode) { return new OverseasFeeSourceDtos.SaveRequest(group.customerId(),group.customerName(),"OVERSEAS_DELIVERY_FEE","DELIVERY_ORDER", delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()), - code,name,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(), + code,name, null,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(), input==null?null:input.settlementMode(),input==null||input.paidAt()==null?null:LocalDateTime.ofInstant(input.paidAt(),ZoneId.systemDefault()), input==null?null:input.paymentMethod(),input==null?null:input.paymentReferenceNo(),input==null?null:input.paymentVoucherFileIds(), LocalDate.now(),currency,amount,quantity,unit,unitPrice,input==null?null:input.periodStart(),input==null?null:input.periodEnd()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java index db4793900..2e0b9ef26 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -16,8 +16,12 @@ public final class PdaCargoDiscardDtos { public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } public record Confirm(@NotEmpty List<@Valid ActualLine> lines, @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds, - List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) { - public Confirm(List lines, List photoFileIds) { this(lines, photoFileIds, null, false); } + List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons, + @NotBlank @Size(max=255) String idempotencyKey) { + /** 兼容已有调用,正式 PDA 必须提交扫码幂等键。 */ + public Confirm(List lines,List photos,List marks,Boolean all){this(lines,photos,marks,all,null);} + /** 兼容已有内部照片确认调用。 */ + public Confirm(List lines, List photoFileIds) { this(lines, photoFileIds, null, false,null); } } /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index 96d5ce1e4..d87d4b978 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -23,6 +23,7 @@ public class PdaCargoDiscardService { @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardService discardService; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService; + @Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; /** 只读取绑定仓已审核申请的实际托盘任务。 */ public List tasks(PdaWarehouseIdentity identity) { @@ -95,6 +96,9 @@ public class PdaCargoDiscardService { // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; if (terminal) throw conflict("废弃终态不能执行未完成托盘"); + storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest( + "DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null, + request.lines(),request.photoFileIds(),request.scannedCartonMarks(),request.confirmAllCartons())); LocalDateTime now = LocalDateTime.now(); for (CargoDiscardItem item : palletItems) { if ("COMPLETED".equals(item.getPdaStatus())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 48107fd17..b408a93b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -50,6 +50,7 @@ import java.util.Set; @Service public class CargoDiscardService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; @Resource private CargoDiscardStatisticsService statisticsService; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @@ -389,6 +390,7 @@ public class CargoDiscardService { release(customerId, application, links, items.listApplication(customerId, application.getId())); LocalDateTime at = now(); String from = application.getStatus(); + storageRent.cancelExecution("DISCARD",discardNo); application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy()); application.setReviewRemark(platform ? "平台取消" : application.getReviewRemark()); application.setUpdatedAt(at); applications.updateById(application); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 2380891d3..8897c9686 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class); @jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @@ -376,6 +377,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA // 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。 var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); releaseSources(row,actor,items,now()); + storageRent.cancelExecution("DISPATCH",number); fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); return saveCommand(row,actor,identity,request.reason()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 9faad5cba..3cd6b2940 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -23,6 +23,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ @Service public class DispatchPickingService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchService sourceRules; private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; @@ -66,6 +67,10 @@ public class DispatchPickingService { if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType()); List relabelAllocations=List.of(); + // 原扫描已经通过业务门禁后才消费已确认资金冻结,之后任一库存失败整体回滚。 + storageRent.consume(actor.boundWarehouseId(), new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest( + "DISPATCH", number, itemId, request.positionId(), request.packages(), request.cartonMark(), null, + request.idempotencyKey(), java.util.List.of(), null, request.productQuantity())); if(nativeReturn) { attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index e1a1b37a2..6a31356f1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -46,6 +46,7 @@ public final class LastlegOrderRequests { if (page == null || page == 0) page = 1L; if (size == null || size == 0) size = 20L; } + } public record ContainerProgressQuery( @@ -100,6 +101,7 @@ public final class LastlegOrderRequests { public record CreateFee( @NotBlank @Size(max = 64) String feeItemCode, @NotBlank @Size(max = 200) String feeItemName, + @Size(max = 500) String feeRemark, @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, @NotBlank @Pattern(regexp = "CNY|USD") String currency, @NotNull @DecimalMin(value = "0.01") BigDecimal amount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/client/OverseasFeeCustomerClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/client/OverseasFeeCustomerClient.java new file mode 100644 index 000000000..4a4a6d278 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/client/OverseasFeeCustomerClient.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.financefee.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** 海外费用只通过 IAM 内部接口读取客户主档,不跨库查询。 */ +@FeignClient(name = "iam-service", contextId = "overseasFeeCustomerClient", url = "${baiyue.clients.iam.url:}") +public interface OverseasFeeCustomerClient { + /** 按客户精确查名,不限制启用状态,以保留停用客户的历史费用展示。 */ + @GetMapping("/internal/iam/finance-customers") + ApiResponse> customers(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("page") long page, @RequestParam("size") long size, + @RequestParam("customerId") long customerId); + + record Customer(Long id, String tenantName) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/config/OverseasFeeResponseJsonConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/config/OverseasFeeResponseJsonConfiguration.java new file mode 100644 index 000000000..d6a8e1897 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/config/OverseasFeeResponseJsonConfiguration.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.financefee.config; + +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService.RetryRecord; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** 费用系统时间按 UTC 存储;仅响应序列化声明时区,不修改业务录入时间和实体类型。 */ +@Configuration +public class OverseasFeeResponseJsonConfiguration { + /** 费用接口目前直接返回实体,通过专属混入避免改变其他业务 LocalDateTime 的含义。 */ + @Bean + public SimpleModule overseasFeeResponseTimeModule() { + var module = new SimpleModule("overseas-fee-response-time"); + module.setMixInAnnotation(OverseasFeeSource.class, FeeTimes.class); + module.setMixInAnnotation(RetryRecord.class, RetryTimes.class); + return module; + } + + abstract static class FeeTimes { + @JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getCreatedAt(); + @JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getReviewedAt(); + @JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime getUpdatedAt(); + } + + abstract static class RetryTimes { + @JsonSerialize(using = UtcTimeSerializer.class) public abstract LocalDateTime requestedAt(); + } + + /** 补充 UTC 标记而非偏移数值,浏览器可以按用户时区显示同一时刻。 */ + public static class UtcTimeSerializer extends JsonSerializer { + @Override public void serialize(LocalDateTime value, JsonGenerator output, SerializerProvider provider) throws IOException { + output.writeString(java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value.atOffset(ZoneOffset.UTC))); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeCatalogController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeCatalogController.java new file mode 100644 index 000000000..c0250e069 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeCatalogController.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.financefee.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.financefee.service.FeeCatalogGateway; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** 海外费用录入的只读项目候选,复用费用列表权限。 */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources/fee-items") +public class PlatformOverseasFeeCatalogController { + private final FeeCatalogGateway catalog; + + /** 返回当前平台可用于海外收费的公共费用项目。 */ + @GetMapping + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "RECEIVABLE") String direction) { + if (user == null || !Long.valueOf(0).equals(user.tenantId()) || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅平台人员可查询费用项目"); + } + return ApiResponse.success(catalog.listActive(direction)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java index 81cb13590..f17251776 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.financefee.controller; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest; @@ -10,6 +11,10 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.financefee.service.OverseasManualFeeService; +import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -32,20 +37,26 @@ import java.util.List; @RequestMapping("/api/platform/v1/last-leg/finance-fee-sources") public class PlatformOverseasFeeSourceController { private final OverseasFeeSourceService service; + private final OverseasManualFeeService manual; + private final OverseasFeeDeliveryService delivery; + /** 查询海外费用当前版本。 */ @GetMapping - @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @RequestParam(required = false) String businessType, @RequestParam(required = false) String businessNo, @RequestParam(required = false) Long containerBookingId) { requirePlatform(user); - return ApiResponse.success(service.list(customerId, businessType, businessNo, containerBookingId)); + var result = manual.describe(service.list(customerId, businessType, businessNo, containerBookingId)); + delivery.describe(result); + return ApiResponse.success(result); } + /** 查询真实柜下可计费客户。 */ @GetMapping("/container-customers") - @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") public ApiResponse> listContainerCustomers( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long containerBookingId) { @@ -53,47 +64,139 @@ public class PlatformOverseasFeeSourceController { return ApiResponse.success(service.listContainerCustomers(containerBookingId)); } + /** 创建人工费用并开始锁定余额及授信。 */ @PostMapping @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) { - requirePlatform(user); return ApiResponse.success(service.create(operator(user), key, request)); + requirePlatform(user); return ApiResponse.success(manual.create(operator(user), key, request)); } + /** 重新提交费用审核。 */ @PostMapping("/{sourceFeeId}/submit") @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) { - requirePlatform(user); return ApiResponse.success(service.submit(sourceFeeId, operator(user), request.idempotencyKey())); + requirePlatform(user); return ApiResponse.success(manual.submit(sourceFeeId, operator(user), request.idempotencyKey())); } + /** 审核费用并推进对应结算。 */ @PostMapping("/{sourceFeeId}/review") @PreAuthorize("hasAuthority('platform:finance:fee-source:review')") public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) { - requirePlatform(user); return ApiResponse.success(service.review(sourceFeeId, operator(user), request)); + requirePlatform(user); return ApiResponse.success(manual.review(sourceFeeId, operator(user), operatorName(user), request)); } + /** 提交账单费用的调整版本。 */ @PostMapping("/{sourceFeeId}/adjust") @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) { - requirePlatform(user); return ApiResponse.success(service.adjust(sourceFeeId, operator(user), request)); + requirePlatform(user); return ApiResponse.success(manual.adjust(sourceFeeId, operator(user), request)); } + /** 申请作废已推送账单费用。 */ @PostMapping("/{sourceFeeId}/void") @PreAuthorize("hasAuthority('platform:finance:fee-source:review')") public ApiResponse voidSource(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) { - requirePlatform(user); return ApiResponse.success(service.voidSource(sourceFeeId, operator(user), request)); + requirePlatform(user); return ApiResponse.success(manual.voidSource(sourceFeeId, operator(user), request)); } + /** 分页查询海外费用及审核、扣款进度。 */ + @GetMapping("/page") + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String customerId, @RequestParam(required = false) String businessType, + @RequestParam(required = false) String businessNo, @RequestParam(required = false) String feeItemCode, + @RequestParam(required = false) String reviewStatus, @RequestParam(required = false) String walletStatus, + @RequestParam(defaultValue = "RECEIVABLE") String direction, + @RequestParam(required = false) String processingStatus, + @RequestParam(defaultValue = "false") boolean storageRentOnly) { + requirePlatform(user); + var result = manual.page(page, size, customerId, businessType, businessNo, feeItemCode, reviewStatus, walletStatus, direction, processingStatus, storageRentOnly); + delivery.describe(result.records()); + return ApiResponse.success(result); + } + + /** 读取单条费用详情与当前真实可操作性。 */ + @GetMapping("/{sourceFeeId}") + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId) { + requirePlatform(user); + var result = manual.detail(sourceFeeId); delivery.describe(List.of(result)); + return ApiResponse.success(result); + } + + /** 查询费用客户筛选选项。 */ + @GetMapping("/customer-options") + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") + public ApiResponse> customers(@AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); return ApiResponse.success(manual.customers()); + } + + /** 查询本次服务业务单候选。 */ + @GetMapping("/business-options") + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") + public ApiResponse> businessOptions(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String businessType, @RequestParam(required = false) String customerId, + @RequestParam(required = false) String keyword) { + requirePlatform(user); return ApiResponse.success(manual.businessOptions(businessType, customerId, keyword)); + } + + /** 取消未收取人工费用并释放已冻结资金。 */ + @PostMapping("/{sourceFeeId}/cancel") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) { + requirePlatform(user); return ApiResponse.success(manual.cancel(sourceFeeId, operator(user), request.idempotencyKey())); + } + + /** 显式重试已经失败的冻结、扣款或释放指令。 */ + @PostMapping("/{sourceFeeId}/retry-funding") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse retryFunding(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) { + requirePlatform(user); return ApiResponse.success(manual.retry(sourceFeeId, operator(user), auditOperator(user), request.idempotencyKey())); + } + + /** 重试当前费用失败的原投递事件,不新建费用或重复扣款。 */ + @PostMapping("/{sourceFeeId}/retry-delivery") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse retryDelivery(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId, @Valid @RequestBody RetryDeliveryRequest request) { + requirePlatform(user); delivery.retry(sourceFeeId, auditOperator(user), request); + var result = manual.detail(sourceFeeId); delivery.describe(List.of(result)); + return ApiResponse.success(result); + } + + /** 查询单条费用历次重试及实际结果,权限与费用详情保持一致。 */ + @GetMapping("/{sourceFeeId}/retries") + @PreAuthorize("hasAnyAuthority('platform:overseas-fee:list','platform:finance:customer-arap:list')") + public ApiResponse> retries(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long sourceFeeId) { + requirePlatform(user); return ApiResponse.success(delivery.history(sourceFeeId)); + } + + /** 操作人从认证身份取得,保留名称与ID,不能由浏览器指定。 */ + private static String auditOperator(AuthenticatedUser user) { + String value = operatorName(user) + " [" + operator(user) + "]"; + return value.length() <= 128 ? value : value.substring(0, 128); + } + + /** 海外费用平台入口不接受客户身份。 */ private static void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可操作海外费用"); } + /** 记录当前审核或创建操作人。 */ private static String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + /** 为审核留存平台操作人的名称快照,方便费用明细直接追溯。 */ + private static String operatorName(AuthenticatedUser user) { + return user.displayName() == null || user.displayName().isBlank() ? user.username() : user.displayName().trim(); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java index 3b07a36d4..9b9ff448b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java @@ -17,11 +17,14 @@ public final class OverseasFeeSourceDtos { public record ContainerCustomerOption(String customerId, String customerName) { } + public record BusinessOption(Long businessId, String businessType, String businessNo, String customerId, + String customerName, Long warehouseId, Long orderId, String orderType) { } + public record SaveRequest( @NotBlank @Size(max = 128) String customerId, @Size(max = 200) String customerName, @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType, - @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType, + @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER|WAREHOUSE_TRANSFER|RETURN_ORDER|DISCARD_ORDER") String businessType, @Min(1) Long businessId, @NotBlank @Size(max = 128) String businessNo, @Min(1) Long orderId, @@ -33,11 +36,14 @@ public final class OverseasFeeSourceDtos { @Min(1) Long warehouseId, @NotBlank @Size(max = 64) String feeItemCode, @NotBlank @Size(max = 200) String feeItemName, + /** 业务人员补收或重复作业时填写的简短说明,和审核驳回原因分开保存。 */ + @Size(max = 500) String feeRemark, @NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, @Min(1) Long supplierId, @Size(max = 200) String supplierName, @Size(max = 32) String supplierCategory, - @Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, + /** 应收可选 BILL(推送结算)或 WALLET(余额扣费);应付仍使用账期/已付。 */ + @Pattern(regexp = "BILL|WALLET|ON_ACCOUNT|DIRECT_PAID") String settlementMode, LocalDateTime paidAt, @Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, @Size(max = 128) String paymentReferenceNo, @@ -53,9 +59,16 @@ public final class OverseasFeeSourceDtos { ) { } public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { } + public record RetryDeliveryRequest(@NotNull @Min(1) Integer expectedVersion, + @NotBlank @Size(max = 64) String eventId, + @NotBlank @Pattern(regexp = "[0-9a-fA-F-]{36}") String idempotencyKey) { } public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, @Size(max = 500) String reason, - @NotBlank @Size(max = 128) String idempotencyKey) { } + @NotBlank @Size(max = 128) String idempotencyKey, + @Min(1) @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) Integer expectedVersion) { + /** 兼容既有人工费用调用,仓租审核另要求明确当前版本。 */ + public ReviewRequest(String decision,String reason,String idempotencyKey){this(decision,reason,idempotencyKey,null);} + } public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion, @NotNull SaveRequest fee, @NotBlank @Size(max = 128) String idempotencyKey) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeFunding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeFunding.java new file mode 100644 index 000000000..58832efb1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeFunding.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.financefee.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.Map; + +/** 人工费用的资金指令状态;实际余额和账本始终由 Finance 维护。 */ +@Data +@TableName(value = "ll_finance_fee_funding", autoResultMap = true) +public class OverseasFeeFunding { + @TableId private Long sourceFeeId; + private String customerId; + private String collectionMode; + private String walletStatus; + private String walletBusinessNo; + private String commandEventId; + private String walletHoldId; + private String walletLedgerId; + private Long commandVersion; + @TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String errorMessage; + @TableField(typeHandler = JacksonTypeHandler.class) private Map commandHistory; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeRetryAudit.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeRetryAudit.java new file mode 100644 index 000000000..52d17cf1b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeRetryAudit.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.financefee.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 复用本库人工重试审计;查询字段保留每次操作对应的原事件与资金回执。 */ +@Data +public class OverseasFeeRetryAudit { + private String requestId; + private String producerTable; + private String entryId; + private String eventId; + private String eventType; + private String payloadHash; + private String operatorName; + private String reason; + private Integer previousFailures; + private String previousError; + private LocalDateTime requestedAt; + private String currentStatus; + private String currentError; + private String walletResult; + private LocalDateTime walletCompletedAt; + private String attemptOutcome; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java index ebae08ff7..d9f239866 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java @@ -15,6 +15,12 @@ import java.util.List; @Data @TableName(value = "ll_finance_fee_source", autoResultMap = true) public class OverseasFeeSource { + /** 自动仓租本次出库依据;查询时装配,不复制库存和资金账本。 */ + @TableField(exist = false) private RentDetail storageRent; + public record RentLine(long lotId, java.time.OffsetDateTime inboundAt, java.math.BigDecimal quantity, long days, + java.math.BigDecimal calculatedAmount, String sourceBusinessType, String sourceBusinessNo, String batchNo) { } + public record RentDetail(String outboundNo, java.time.OffsetDateTime outboundAt, + java.math.BigDecimal calculatedAmount, java.math.BigDecimal chargedAmount, java.util.List lines) { } @TableId(type = IdType.INPUT) private Long id; private Long sourceFeeId; private Integer sourceVersion; @@ -23,6 +29,7 @@ public class OverseasFeeSource { private String sourceType; private String sourceNo; private String businessType; + @TableField(exist = false) private String displayBusinessType; private Long businessId; private String businessNo; private Long orderId; @@ -34,6 +41,8 @@ public class OverseasFeeSource { private Long warehouseId; private String feeItemCode; private String feeItemName; + /** 费用业务说明,不用于记录审核结论。 */ + private String feeRemark; private String direction; private Long supplierId; private String supplierName; @@ -50,6 +59,8 @@ public class OverseasFeeSource { private BigDecimal amount; private String originalCurrency; private BigDecimal originalAmount; + /** 免费仓租显式写入空汇率,避免存量库默认1被误当美元调价汇率。 */ + @TableField(insertStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private BigDecimal exchangeRate; private String exchangeRateMonth; private BigDecimal chargeableQuantity; @@ -61,8 +72,21 @@ public class OverseasFeeSource { private String publicationStatus; private String sourceStatus; @TableField(exist = false) private String voidStatus; + @TableField(exist = false) private String collectionMode; + @TableField(exist = false) private String walletStatus; + @TableField(exist = false) private String walletErrorMessage; + @TableField(exist = false) private String deliveryStatus; + @TableField(exist = false) private String deliveryKind; + @TableField(exist = false) private String deliveryEventId; + @TableField(exist = false) private String deliveryErrorMessage; + @TableField(exist = false) private List allowedActions; + @TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private String reviewReason; + @TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private String reviewerId; + @TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String reviewerName; + @TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private LocalDateTime reviewedAt; private String createdBy; private String updatedBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java new file mode 100644 index 000000000..082206624 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.financefee.mapper; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.SelectProvider; +import java.util.List; +import java.util.Map; + +/** 只读取 Lastleg 自有业务事实,客户条件不可省略。 */ +@Mapper +public interface OverseasFeeBusinessMapper { + /** 查询业务记录是否属于当前客户和单号。 */ + @SelectProvider(type = Sql.class, method = "owned") + int countOwned(@Param("type") String type, @Param("id") long id, + @Param("customerId") String customerId, @Param("businessNo") String businessNo); + /** 补充关联也必须属于相同客户,不能夹带其他客户的订单或入仓信息。 */ + @SelectProvider(type = Sql.class, method = "customerOwned") + int countCustomerOwned(@Param("type") String type, @Param("id") long id, @Param("customerId") String customerId); + /** 校验入仓码属于对应客户计划。 */ + @Select("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=#{id} AND customer_id=#{customerId} AND inbound_code=#{code}") + int countInbound(@Param("id") long id, @Param("customerId") String customerId, @Param("code") String code); + /** 校验真实柜存在。 */ + @Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId}") + int countContainer(@Param("bookingId") String bookingId); + /** 拒绝同一来源柜中不一致的真实柜号。 */ + @Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId} AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no) <> BINARY #{containerNo})") + int countInconsistentContainer(@Param("bookingId") String bookingId, @Param("containerNo") String containerNo); + /** 校验费用客户确实属于此柜。 */ + @Select("SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=#{bookingId} AND customer_id=#{customerId}") + int countContainerCustomer(@Param("bookingId") String bookingId, @Param("customerId") String customerId); + /** 按来源柜一次聚合客户。 */ + @Select("SELECT customer_id,MAX(NULLIF(TRIM(customer_name),'')) customer_name FROM ll_order WHERE source_container_booking_id=#{bookingId} GROUP BY customer_id ORDER BY customer_id") + List containerCustomers(@Param("bookingId") String bookingId); + /** 客户筛选只取本服务已有真实业务和费用客户。 */ + @Select("SELECT customer_id,MAX(customer_name) customer_name FROM (SELECT customer_id,customer_name FROM ll_order UNION ALL SELECT customer_id,customer_name FROM ll_finance_fee_source" + + " UNION ALL SELECT customer_id,NULL FROM ll_warehouse_transfer_order UNION ALL SELECT customer_id,NULL FROM ll_return_order" + + " UNION ALL SELECT customer_id,NULL FROM ll_us_truck_order UNION ALL SELECT customer_id,NULL FROM ll_local_looseboard_order" + + " UNION ALL SELECT customer_id,NULL FROM ll_relabel_application UNION ALL SELECT customer_id,NULL FROM ll_cargo_discard_application" + + " UNION ALL SELECT customer_id,NULL FROM ll_inbound_plan UNION ALL SELECT customer_id,NULL FROM ll_delivery_order) c" + + " WHERE customer_id IS NOT NULL AND TRIM(customer_id)!='' GROUP BY customer_id ORDER BY customer_id LIMIT 1000") + List customers(); + /** 头程附加费只认本次服务订单的类型,不能沿货物最初来源推断。 */ + @Select("SELECT order_type FROM ll_order WHERE id=#{id} AND customer_id=#{customerId}") + String orderType(@Param("id") long id, @Param("customerId") String customerId); + /** 换标履约订单登记费用时,定位同客户的真实换标申请。 */ + @Select("SELECT a.id business_id,'RELABEL_ORDER' business_type,a.relabel_no business_no,a.customer_id," + + "o.customer_name,o.overseas_warehouse_id warehouse_id,o.id order_id,o.order_type" + + " FROM ll_relabel_application a JOIN ll_order o ON o.id=a.lastleg_order_id AND o.customer_id=a.customer_id" + + " WHERE o.id=#{id} AND o.customer_id=#{customerId} AND o.order_type='RELABEL_APPLICATION'") + BusinessOption relabelForOrder(@Param("id") long id, @Param("customerId") String customerId); + /** 批量按真实客户和订单类型识别换标费用,不根据单号前缀猜测。 */ + @Select("") + List relabelFeeIds(@Param("ids") List ids); + /** 按客户与关键字返回真实业务候选。 */ + @SelectProvider(type = Sql.class, method = "options") + List options(@Param("type") String type, @Param("customerId") String customerId, + @Param("keyword") String keyword); + + final class Sql { + /** 业务表和单号字段只由服务端白名单解析,禁止请求传入 SQL 标识符。 */ + private static String[] table(String type) { + return switch (type) { + case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"}; + case "OVERSEAS_PROCUREMENT" -> new String[]{"ll_overseas_procurement_order", "order_no", "NULL", "NULL"}; + case "PROCUREMENT_FEE" -> new String[]{"ll_overseas_procurement_fee", "fee_no", "NULL", "NULL"}; + case "US_TRUCK" -> new String[]{"ll_us_truck_order", "order_no", "NULL", "NULL"}; + case "LOCAL_LOOSEBOARD" -> new String[]{"ll_local_looseboard_order", "order_no", "NULL", "NULL"}; + case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"}; + case "DELIVERY_ORDER" -> new String[]{"ll_delivery_order", "delivery_no", "NULL", "NULL"}; + case "RELABEL_ORDER" -> new String[]{"ll_relabel_application", "relabel_no", "NULL", "NULL"}; + case "WAREHOUSE_TRANSFER" -> new String[]{"ll_warehouse_transfer_order", "transfer_no", "t.operating_warehouse_id", "NULL"}; + case "RETURN_ORDER" -> new String[]{"ll_return_order", "return_no", "t.overseas_warehouse_id", "NULL"}; + case "DISCARD_ORDER" -> new String[]{"ll_cargo_discard_application", "discard_no", "NULL", "NULL"}; + default -> throw new IllegalArgumentException("不支持的费用业务类型"); + }; + } + /** 生成精确业务归属查询。 */ + public static String owned(Map args) { + String[] table = table((String) args.get("type")); + return "SELECT COUNT(*) FROM " + table[0] + " WHERE id=#{id} AND customer_id=#{customerId} AND " + table[1] + "=#{businessNo}"; + } + /** 只核对补充引用的主键及客户,业务主关联另行核对单号。 */ + public static String customerOwned(Map args) { + String[] table = table((String) args.get("type")); + return "SELECT COUNT(*) FROM " + table[0] + " WHERE id=#{id} AND customer_id=#{customerId}"; + } + /** 候选限制最多五十条;输入只作为绑定参数。 */ + public static String options(Map args) { + String type = (String) args.get("type"); + String[] table = table(type); + return ""; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeFundingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeFundingMapper.java new file mode 100644 index 000000000..403082a20 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeFundingMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.financefee.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface OverseasFeeFundingMapper extends BaseMapper { + /** 锁定费用资金编排,避免审核、取消和异步回执相互覆盖。 */ + @Select("SELECT * FROM ll_finance_fee_funding WHERE source_fee_id=#{sourceFeeId} FOR UPDATE") + @org.apache.ibatis.annotations.ResultMap("mybatis-plus_OverseasFeeFunding") + OverseasFeeFunding lock(@Param("sourceFeeId") long sourceFeeId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeRetryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeRetryMapper.java new file mode 100644 index 000000000..4254595a2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeRetryMapper.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.financefee.mapper; + +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 重试只重置原事件状态;费用、金额及事件载荷不可修改。 */ +@Mapper +public interface OverseasFeeRetryMapper { + @Select("SELECT * FROM ll_event_outbox WHERE event_key=#{key} FOR UPDATE") + LastlegEventOutbox lockEvent(@Param("key") String key); + + @Select("SELECT * FROM event_delivery_retry_audit WHERE request_id=#{requestId}") + OverseasFeeRetryAudit request(@Param("requestId") String requestId); + + /** 仅重新排队失败事件,不抢占发送租约;清除旧失败以禁止连续重复点击。 */ + @Update("UPDATE ll_event_outbox SET status='PENDING',retry_count=0,next_retry_at=#{now},locked_by=NULL,locked_at=NULL,last_error=NULL WHERE id=#{id} AND (status='DEAD' OR (status='PENDING' AND NULLIF(TRIM(last_error),'') IS NOT NULL))") + int requeue(@Param("id") long id, @Param("now") LocalDateTime now); + + @Insert("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(#{requestId},#{producerTable},#{entryId},#{eventId},#{eventType},#{payloadHash},#{operatorName},#{reason},#{previousFailures},#{previousError},#{requestedAt})") + int insert(OverseasFeeRetryAudit audit); + + /** 跨版本历史按稳定费用身份归集;事件和记录标识按字节精确关联,避免表间排序规则冲突或串记录。 */ + @Select("SELECT a.*,e.status current_status,COALESCE((SELECT newer.previous_error FROM event_delivery_retry_audit newer WHERE newer.event_id=a.event_id AND newer.requested_at > a.requested_at ORDER BY newer.requested_at LIMIT 1),e.last_error) current_error,i.payload_json wallet_result,i.processed_at wallet_completed_at, " + + "(SELECT d.outcome FROM event_delivery_attempt d WHERE CAST(d.event_id AS BINARY)=CAST(a.event_id AS BINARY) AND d.started_at >= a.requested_at " + + "AND d.started_at < COALESCE((SELECT MIN(newer.requested_at) FROM event_delivery_retry_audit newer WHERE newer.event_id=a.event_id AND newer.requested_at > a.requested_at),'9999-12-31 23:59:59') ORDER BY d.started_at DESC,d.attempt_id DESC LIMIT 1) attempt_outcome " + + "FROM event_delivery_retry_audit a JOIN ll_event_outbox e ON a.producer_table='ll_event_outbox' AND CAST(a.entry_id AS BINARY)=CAST(CONCAT('',e.id) AS BINARY) " + + "LEFT JOIN ll_wallet_event_inbox i ON CAST(i.command_event_id AS BINARY)=CAST(a.event_id AS BINARY) AND i.status='PROCESSED' " + + "WHERE e.customer_id=#{customer} AND ((e.event_type='BusinessFeeSourceChanged' AND e.aggregate_type=#{sourceType} AND e.aggregate_id=#{sourceId}) " + + "OR (e.event_type='WalletCommandRequested' AND e.aggregate_type='STORAGE_RENT' AND CAST(e.aggregate_id AS BINARY) IN (SELECT CAST(CONCAT('',r.id) AS BINARY) FROM ll_storage_rent_preparation r WHERE r.source_fee_id=#{sourceId} AND r.customer_id=#{customer})) OR (e.event_type='WalletCommandRequested' AND e.aggregate_type='OVERSEAS_MANUAL_FEE' AND e.aggregate_id LIKE CONCAT(#{sourceId},'-%'))) " + + "ORDER BY a.requested_at DESC,a.request_id DESC") + List history(@Param("customer") String customer, @Param("sourceType") String sourceType, @Param("sourceId") String sourceId); + +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasManualFeeQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasManualFeeQueryMapper.java new file mode 100644 index 000000000..1e32a1bba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasManualFeeQueryMapper.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.financefee.mapper; + +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.SelectProvider; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.ResultMap; +import java.util.List; +import java.util.Map; + +@Mapper +public interface OverseasManualFeeQueryMapper { + /** 查询最新费用版本的数量,所有筛选与分页查询一致。 */ + @SelectProvider(type = Sql.class, method = "count") + long count(@Param("query") Map query); + /** 返回稳定排序的当前版本,避免财务费用列表无限加载。 */ + @SelectProvider(type = Sql.class, method = "page") + @ResultMap("com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper.mybatis-plus_OverseasFeeSource") + List page(@Param("query") Map query); + + /** 同客户、业务单及方向核对现有项目;已驳回记录也保留,允许说明后重新登记。 */ + @Select("SELECT COUNT(*) FROM ll_finance_fee_source f WHERE f.customer_id=#{customer}" + + " AND f.business_type=#{type} AND f.business_id=#{businessId} AND f.direction=#{direction}" + + " AND f.fee_item_code=#{code} AND f.source_status='ACTIVE' AND f.source_fee_id != #{exclude}" + + " AND NOT EXISTS(SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)") + int sameProject(@Param("customer") String customer, @Param("type") String type, @Param("businessId") long businessId, + @Param("direction") String direction, @Param("code") String code, @Param("exclude") long exclude); + + /** 只拦截旧自动链的同项目,新人工费用允许按实际服务分次登记。 */ + @Select("") + int legacyProject(@Param("customer") String customer,@Param("type") String type,@Param("id") long id, + @Param("code") String code,@Param("name") String name,@Param("exclude") long exclude); + + /** 尚未生成费用来源的旧换标冻结及已扣快照也必须防重。 */ + @Select("") + int legacyRelabel(@Param("customer") String customer,@Param("type") String type,@Param("id") long id); + + final class Sql { + /** 绑定动态筛选,不拼接任何请求值。 */ + private static String where() { + return " FROM ll_finance_fee_source f LEFT JOIN ll_finance_fee_funding w ON w.source_fee_id=f.source_fee_id" + + " WHERE NOT EXISTS (SELECT 1 FROM ll_finance_fee_source n WHERE n.source_fee_id=f.source_fee_id AND n.source_version>f.source_version)" + + " AND f.customer_id=#{query.customerId}" + + " AND (CASE WHEN f.business_type='LASTLEG_ORDER'" + + " AND EXISTS(SELECT 1 FROM ll_order o WHERE o.id=f.business_id AND o.customer_id=f.customer_id" + + " AND o.order_type='RELABEL_APPLICATION') THEN 'RELABEL_ORDER' ELSE f.business_type END)=#{query.businessType}" + + " AND f.business_no LIKE CONCAT('%',#{query.businessNo},'%')" + + " AND f.fee_item_code=#{query.feeItemCode}" + // 分页和总数共用来源过滤,人工同名仓储费不混入出库仓租记录。 + + " AND EXISTS(SELECT 1 FROM ll_storage_rent_preparation r WHERE r.source_fee_id=f.source_fee_id AND r.customer_id=f.customer_id AND r.consumed_at IS NOT NULL)" + + " AND f.review_status=#{query.reviewStatus}" + + " AND (w.wallet_status=#{query.walletStatus}" + + " OR (w.source_fee_id IS NULL AND f.wallet_settlement IS NOT NULL))" + + " AND f.direction=#{query.direction}" + + " AND (" + processingStatus() + ")=#{query.processingStatus}"; + } + /** 按当前版本唯一事件归并费用状态,列表总数与分页共用同一表达式。 */ + private static String processingStatus() { + String eventKey = "CASE WHEN f.review_status='APPROVED' AND f.publication_status='PUBLISHED' THEN " + + "CONCAT('BusinessFeeSourceChanged:LASTLEG:',f.source_type,':',f.source_fee_id,':',f.source_version," + + "CASE WHEN f.source_version=1 THEN ':PUBLISHED' ELSE ':ADJUSTED' END)" + + " WHEN w.collection_mode='WALLET' AND w.wallet_status IN ('FREEZE_PENDING','SETTLE_PENDING','RELEASE_PENDING') THEN " + + "CONCAT('WALLET_COMMAND:',f.customer_id,':',CASE WHEN f.created_by='STORAGE_RENT' AND f.fee_item_code='FEE_TYPE_077' THEN 'STORAGE_RENT' ELSE 'OVERSEAS_MANUAL_FEE' END,':',w.wallet_business_no,':'," + + "CASE WHEN f.created_by='STORAGE_RENT' AND f.fee_item_code='FEE_TYPE_077' AND w.wallet_status='SETTLE_PENDING' THEN 'SETTLE_ACTUAL' ELSE REPLACE(w.wallet_status,'_PENDING','') END,':',w.command_version) ELSE NULL END"; + String delivery = "(SELECT e.status FROM ll_event_outbox e WHERE e.event_key=(" + eventKey + ") AND e.customer_id=f.customer_id)"; + String deliveryFailed = "EXISTS(SELECT 1 FROM ll_event_outbox e WHERE e.event_key=(" + eventKey + ") AND e.customer_id=f.customer_id " + + "AND e.status IN ('PENDING','PROCESSING') AND NULLIF(TRIM(e.last_error),'') IS NOT NULL)"; + // 费用已终结不代表资金已释放;补偿失败须能从失败筛选中找到。 + return "CASE WHEN w.wallet_status='RELEASE_FAILED' THEN 'FAILED'" + + " WHEN w.wallet_status IN ('FREEZE_PENDING','RELEASE_PENDING') AND (" + delivery + "='DEAD' OR " + deliveryFailed + ") THEN 'FAILED'" + + " WHEN w.wallet_status IN ('FREEZE_PENDING','RELEASE_PENDING') THEN 'PROCESSING'" + + " WHEN f.source_status IN ('VOIDED','SUPERSEDED','CANCELLED') OR f.review_status='REJECTED' THEN 'CLOSED'" + + " WHEN f.direction='RECEIVABLE' AND (w.wallet_status='SETTLED' OR f.wallet_settlement IS NOT NULL) THEN 'COMPLETED'" + + " WHEN w.wallet_status IN ('FREEZE_FAILED','SETTLE_FAILED','RELEASE_FAILED') OR " + delivery + "='DEAD' OR " + deliveryFailed + " THEN 'FAILED'" + + " WHEN w.wallet_status IN ('FREEZE_PENDING','SETTLE_PENDING','RELEASE_PENDING') OR " + delivery + " IN ('PENDING','PROCESSING') THEN 'PROCESSING'" + + " WHEN w.wallet_status='SETTLED' OR f.wallet_settlement IS NOT NULL OR f.publication_status='PUBLISHED' THEN 'COMPLETED' ELSE 'WAITING' END"; + } + /** 生成同口径总数查询。 */ + public static String count() { return ""; } + /** 生成稳定分页查询。 */ + public static String page() { + return ""; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java new file mode 100644 index 000000000..b0f75a24f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeBusinessRepository.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.financefee.repository; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeBusinessMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OverseasFeeBusinessRepository { + private final OverseasFeeBusinessMapper mapper; + /** 核对真实业务客户与单号。 */ + public int owned(String type, long id, String customer, String no) { return mapper.countOwned(type, id, customer, no); } + /** 补充业务引用也需核对客户,不能只校验主关联。 */ + public int customerOwned(String type, long id, String customer) { return mapper.countCustomerOwned(type, id, customer); } + /** 核对入仓码。 */ + public int inbound(long id, String customer, String code) { return mapper.countInbound(id, customer, code); } + /** 读取真实柜数量。 */ + public int container(String id) { return mapper.countContainer(id); } + /** 核对柜号一致性。 */ + public int inconsistentContainer(String id, String no) { return mapper.countInconsistentContainer(id, no); } + /** 核对柜内客户。 */ + public int containerCustomer(String id, String customer) { return mapper.countContainerCustomer(id, customer); } + /** 一次读取柜内客户。 */ + public List containerCustomers(String id) { return mapper.containerCustomers(id); } + /** 读取费用筛选客户。 */ + public List customers() { return mapper.customers(); } + /** 读取本次业务订单类型。 */ + public String orderType(long id, String customer) { return mapper.orderType(id, customer); } + /** 从履约主键解析实际换标单,保留客户隔离。 */ + public BusinessOption relabelForOrder(long id, String customer) { return mapper.relabelForOrder(id, customer); } + /** 空列表不查询,费用展示采用批量业务事实。 */ + public List relabelFeeIds(List ids) { return ids.isEmpty() ? List.of() : mapper.relabelFeeIds(ids); } + /** 返回真实业务候选。 */ + public List options(String type, String customer, String keyword) { return mapper.options(type, customer, keyword); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeDeliveryRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeDeliveryRepository.java new file mode 100644 index 000000000..acca05cca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeDeliveryRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.financefee.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeRetryMapper; +import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** 原事件及人工重试审计的数据访问,不接触客户余额或其他服务数据库。 */ +@Repository +@RequiredArgsConstructor +public class OverseasFeeDeliveryRepository { + private final LastlegEventOutboxMapper events; + private final OverseasFeeRetryMapper retries; + public List events(Collection keys) { return keys.isEmpty() ? List.of() : events.selectList(new LambdaQueryWrapper().in(LastlegEventOutbox::getEventKey, keys)); } + public LastlegEventOutbox event(String key) { return events.findByEventKey(key); } + public LastlegEventOutbox lock(String key) { return retries.lockEvent(key); } + public OverseasFeeRetryAudit request(String key) { return retries.request(key); } + public int requeue(long id, LocalDateTime now) { return retries.requeue(id, now); } + public void insert(OverseasFeeRetryAudit audit) { retries.insert(audit); } + public List history(String customer, String sourceType, long sourceId) { return retries.history(customer, sourceType, String.valueOf(sourceId)); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java index 57772829a..8a2054844 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -19,9 +19,13 @@ public class OverseasFeeOutboxRepository { public boolean prerequisitePublished(LastlegEventOutbox row) { return cancellation.prerequisitePublished(row); } + /** 仅投递需结算的费用,已确认余额扣款的应收不再发送,避免无效重试占满队列。 */ public List pending(int limit, LocalDateTime now, LocalDateTime stale) { return mapper.selectList(new LambdaQueryWrapper() .eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) + .apply("NOT EXISTS (SELECT 1 FROM ll_finance_fee_source collected WHERE collected.source_fee_id=ll_event_outbox.aggregate_id " + + "AND collected.source_type=ll_event_outbox.aggregate_type AND collected.customer_id=ll_event_outbox.customer_id " + + "AND collected.direction='RECEIVABLE' AND collected.wallet_settlement IS NOT NULL)") .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasManualFeeRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasManualFeeRepository.java new file mode 100644 index 000000000..50438f458 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasManualFeeRepository.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.financefee.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeFundingMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasManualFeeQueryMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** 人工费用读写适配,不持有客户余额。 */ +@Repository +@RequiredArgsConstructor +public class OverseasManualFeeRepository { + private final OverseasFeeSourceMapper fees; + private final OverseasFeeFundingMapper funding; + private final OverseasManualFeeQueryMapper queries; + private final OverseasFeeCommandMapper commands; + + /** 读取操作幂等记录,避免重试审核重复发资金指令。 */ + public OverseasFeeCommand command(String operator, String action, String key) { return commands.find(operator, action, key); } + /** 保存业务操作幂等结果。 */ + public void insertCommand(OverseasFeeCommand value) { commands.insert(value); } + + /** 统计同筛选条件下的费用。 */ + public long count(Map query) { return queries.count(query); } + /** 查询费用分页。 */ + public List page(Map query) { return queries.page(query); } + + /** 重复收费仅要求解释实际服务,不对同单项目设置唯一收费限制。 */ + public boolean hasSameProject(OverseasFeeSource fee) { + return queries.sameProject(fee.getCustomerId(), fee.getBusinessType(), fee.getBusinessId(), + fee.getDirection(), fee.getFeeItemCode(), fee.getSourceFeeId()) > 0; + } + + /** 核对相同服务项目是否已经被旧自动应收占用。 */ + public int legacyProject(String customer,String type,long id,String code,String name,long exclude) { + return queries.legacyProject(customer,type,id,code,name,exclude); + } + /** 读取旧换标资金占用,避免新旧两套资金重复扣款。 */ + public int legacyRelabel(String customer,String type,long id) { return queries.legacyRelabel(customer,type,id); } + + /** 获取费用最新版本。 */ + public OverseasFeeSource current(long id) { + return fees.selectOne(new LambdaQueryWrapper() + .eq(OverseasFeeSource::getSourceFeeId, id).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1")); + } + /** 锁定费用后才能读取或变更资金状态,所有写入口保持相同锁顺序。 */ + public OverseasFeeSource lockFee(long id) { return fees.lockCurrent(id); } + /** 保存本地费用状态,不代表完成钱包扣款。 */ + public void updateFee(OverseasFeeSource fee) { fees.updateById(fee); } + /** 仓租调整保留每个金额版本及其说明,旧版本不覆盖。 */ + public void insertFee(OverseasFeeSource fee) { fees.insert(fee); } + /** 查询已有资金编排。 */ + public OverseasFeeFunding funding(long id) { return funding.selectById(id); } + /** 锁定资金编排。 */ + public OverseasFeeFunding lockFunding(long id) { return funding.lock(id); } + /** 批量加载列表资金状态,避免逐条查询。 */ + public List funding(Collection ids) { + return ids.isEmpty() ? List.of() : funding.selectBatchIds(ids); + } + /** 首次创建资金编排。 */ + public void insertFunding(OverseasFeeFunding value) { funding.insert(value); } + /** 保存资金指令状态及已核验回执。 */ + public void updateFunding(OverseasFeeFunding value) { funding.updateById(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/FeeCatalogGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/FeeCatalogGateway.java new file mode 100644 index 000000000..185e42755 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/FeeCatalogGateway.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.client.FirstlegFeeCatalogClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +/** 海外收费复用公共项目;目录不可用时拒绝新录费,不回退自由输入。 */ +@Service +public class FeeCatalogGateway { + private final FirstlegFeeCatalogClient client; + private final String token; + + /** 注入只读目录客户端及服务身份。 */ + public FeeCatalogGateway(FirstlegFeeCatalogClient client, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.client = client; + this.token = token; + } + + /** 返回真实启用的海外仓应收项目,默认价币种固定为人民币。 */ + public List listActiveReceivable() { + return listActive("RECEIVABLE"); + } + + /** 按收付方向返回当前启用项目,拒绝未知方向。 */ + public List listActive(String direction) { + if (!List.of("RECEIVABLE", "PAYABLE").contains(direction)) { + throw new BizException(ResultCode.BAD_REQUEST, "收付方向无效"); + } + requireConfigured(); + List items = requireData(client.list(token, direction)); + return items.stream().map(item -> new FeeItem(item.code(), item.name(), item.defaultPrice())).toList(); + } + + /** 校验项目编码、名称快照和收费方向,防止伪造项目名写入财务。 */ + public void requireActive(String code, String name, String direction) { + requireConfigured(); + FirstlegFeeCatalogClient.Item item = requireData(client.detail(token, code, direction)); + if (!Objects.equals(code, item.code()) || !Objects.equals(name, item.name())) { + throw new BizException(ResultCode.CONFLICT, "费用项目已变更,请重新选择"); + } + } + + /** 未配置内部服务身份时禁止发送匿名目录请求。 */ + private void requireConfigured() { + if (token == null || token.isBlank()) { + throw new BizException(ResultCode.CONFLICT, "费用项目服务未就绪"); + } + } + + /** 检查业务响应,不能把远端拒绝误认为空目录或有效项目。 */ + private static T requireData(ApiResponse response) { + if (response == null || !"200".equals(response.code()) || response.data() == null) { + throw new BizException(ResultCode.CONFLICT, "费用项目不可用,请刷新后重试"); + } + return response.data(); + } + + public record FeeItem(String code, String name, BigDecimal defaultPrice) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java index 81e541903..3e44314d9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java @@ -8,7 +8,7 @@ import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.JdbcTemplate; +import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository; import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; @@ -17,22 +17,24 @@ import java.util.List; @Component @RequiredArgsConstructor public class OverseasFeeBusinessGuard { - private final JdbcTemplate jdbc; + private final OverseasFeeBusinessRepository repository; @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token}") private String internalToken; + /** 按真实来源柜一次读取客户候选。 */ public List listContainerCustomers(long containerBookingId) { - List customers = jdbc.query( - "SELECT customer_id,CASE WHEN COUNT(DISTINCT NULLIF(TRIM(customer_name),''))=1 " - + "THEN MAX(NULLIF(TRIM(customer_name),'')) ELSE NULL END AS customer_name " - + "FROM ll_order WHERE source_container_booking_id=? GROUP BY customer_id ORDER BY customer_id", - (result, rowNum) -> new ContainerCustomerOption( - result.getString("customer_id"), result.getString("customer_name")), - String.valueOf(containerBookingId)); - return customers; + return repository.containerCustomers(String.valueOf(containerBookingId)); } + /** 校验费用与当前服务业务单的客户、单号一致。 */ public void requireOwned(SaveRequest fee) { + if (fee.orderId() != null && repository.customerOwned("LASTLEG_ORDER", fee.orderId(), fee.customerId().trim()) != 1) + throw bad("关联订单不属于该客户"); + if (fee.inboundId() != null && repository.customerOwned("OVERSEAS_INBOUND", fee.inboundId(), fee.customerId().trim()) != 1) + throw bad("关联入仓计划不属于该客户"); + if (fee.inboundId() != null && !blank(fee.inboundCode()) + && repository.inbound(fee.inboundId(), fee.customerId().trim(), fee.inboundCode().trim()) < 1) + throw bad("入仓码不属于关联入仓计划和客户"); if ("OVERSEAS_CONTAINER".equals(fee.businessType())) { requireOwnedContainer(fee); return; @@ -45,28 +47,21 @@ public class OverseasFeeBusinessGuard { requireActiveOverseasWarehouse(fee.warehouseId()); return; } - Table table = switch (fee.businessType()) { - case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no"); - case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no"); - case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no"); - case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no"); - case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no"); - case "LASTLEG_ORDER" -> new Table("ll_order", "order_no"); - case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no"); - case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no"); - default -> throw bad("业务类型与业务记录ID不匹配"); - }; - Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name - + " WHERE id=? AND customer_id=? AND " + table.number + "=?", - Integer.class, fee.businessId(), fee.customerId().trim(), fee.businessNo().trim()); - if (count == null || count != 1) throw bad("费用关联业务不存在或不属于该客户"); - if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())) { - Integer inbound = jdbc.queryForObject("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=? AND customer_id=? AND inbound_code=?", - Integer.class, fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim()); - if (inbound == null || inbound < 1) throw bad("入仓码不属于该客户和海外入仓计划"); + int count; + try { + count = repository.owned(fee.businessType(), fee.businessId(), fee.customerId().trim(), fee.businessNo().trim()); + } catch (IllegalArgumentException exception) { + throw bad("业务类型与业务记录ID不匹配"); + } + if (count != 1) throw bad("费用关联业务不存在或不属于该客户"); + if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode()) + && repository.inbound(fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim()) < 1) { + throw bad("入仓码不属于该客户和海外入仓计划"); } } + /** 真实柜费用既核对柜号,也核对柜内客户,不能跨客户挂账。 */ + private void requireOwnedContainer(SaveRequest fee) { if (fee.businessId() == null || fee.containerBookingId() == null || !fee.businessId().equals(fee.containerBookingId())) @@ -75,24 +70,15 @@ public class OverseasFeeBusinessGuard { throw bad("海外货柜费用的业务编号必须与真实集装箱号一致"); String bookingId = String.valueOf(fee.containerBookingId()); - Integer total = jdbc.queryForObject( - "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=?", - Integer.class, bookingId); - if (total == null || total < 1) throw bad("费用关联的真实货柜不存在"); - - Integer inconsistent = jdbc.queryForObject( - "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? " - + "AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no)<>BINARY ?)", - Integer.class, bookingId, fee.containerNo().trim()); - if (inconsistent == null || inconsistent > 0) + if (repository.container(bookingId) < 1) throw bad("费用关联的真实货柜不存在"); + if (repository.inconsistentContainer(bookingId, fee.containerNo().trim()) > 0) throw bad("真实货柜的集装箱号缺失或不一致"); - - Integer owned = jdbc.queryForObject( - "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? AND customer_id=?", - Integer.class, bookingId, fee.customerId().trim()); - if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜"); + if (repository.containerCustomer(bookingId, fee.customerId().trim()) < 1) + throw bad("该客户不属于费用关联的真实货柜"); } + /** 无业务实体的历史仓服务只允许有效共享海外仓。 */ + private void requireActiveOverseasWarehouse(long warehouseId) { try { var response = warehouses.activeWarehouse(warehouseId, internalToken); @@ -108,9 +94,10 @@ public class OverseasFeeBusinessGuard { } } + /** 判断业务可选字段是否为空。 */ private static boolean blank(String value) { return value == null || value.isBlank(); } + /** 返回业务参数错误。 */ private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } - private record Table(String name, String number) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNames.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNames.java new file mode 100644 index 000000000..47a89bbd3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNames.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.client.OverseasFeeCustomerClient; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; + +/** 费用查询按客户 ID 展示当前主档名称,历史快照只在查名不可用时兜底,不回写财务历史。 */ +@Component +public class OverseasFeeCustomerNames { + private static final Logger log = LoggerFactory.getLogger(OverseasFeeCustomerNames.class); + private final OverseasFeeCustomerClient client; + private final String token; + + /** 使用现有 IAM 地址和内部服务身份查询客户。 */ + public OverseasFeeCustomerNames(OverseasFeeCustomerClient client, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.client = client; + this.token = token; + } + + /** 同一客户只查询一次主档,防止列表混用旧名称而与详情、财务不一致。 */ + public Map resolve(Collection customers) { + Map names = new LinkedHashMap<>(); + var ids = new LinkedHashSet(); + for (var customer : customers) { + if (customer.customerId() == null || customer.customerId().isBlank()) continue; + ids.add(customer.customerId()); + if (customer.customerName() != null && !customer.customerName().isBlank()) + names.putIfAbsent(customer.customerId(), customer.customerName()); + } + if (token == null || token.isBlank()) return names; + for (String id : ids) { + long customerId; + try { customerId = Long.parseLong(id); } + catch (NumberFormatException invalid) { continue; } + if (customerId <= 0) continue; + try { + var response = client.customers(token, 1, 1, customerId); + if (response == null || !response.successful() || response.data() == null) { + log.warn("海外费用客户名称查询暂不可用"); + break; + } + response.data().records().stream() + .filter(customer -> Long.valueOf(customerId).equals(customer.id())) + .map(OverseasFeeCustomerClient.Customer::tenantName) + .filter(name -> name != null && !name.isBlank()).findFirst() + .ifPresent(name -> names.put(id, name.trim())); + } catch (RuntimeException unavailable) { + // 查名失败不能隐藏费用或影响资金状态;停止本轮远端调用,后续查询可重试。 + log.warn("海外费用客户名称查询失败,保留费用原快照", unavailable); + break; + } + } + return names; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryService.java new file mode 100644 index 000000000..98d74be09 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryService.java @@ -0,0 +1,217 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.*; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository; +import com.baiyue.lastleg.financefee.repository.OverseasFeeDeliveryRepository; +import com.baiyue.lastleg.financefee.entity.OverseasFeeRetryAudit; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.nio.charset.StandardCharsets; + +/** 费用明细只重投原事件;沿用事件身份、摘要和运维审计,不能重新生成收费。 */ +@Service +@RequiredArgsConstructor +public class OverseasFeeDeliveryService { + private final OverseasFeeDeliveryRepository repository; + private final OverseasManualFeeRepository fees; + private final ObjectMapper json; + + /** 批量读取当前版本的实际投递结果,不把已写入队列显示为财务已接收。 */ + public void describe(List rows) { + if (rows.isEmpty()) return; + var states = fees.funding(rows.stream().map(OverseasFeeSource::getSourceFeeId).toList()).stream() + .collect(Collectors.toMap(OverseasFeeFunding::getSourceFeeId, Function.identity())); + Map keys = new HashMap<>(); + for (var fee : rows) { + if (fee.getAllowedActions() != null) fee.setAllowedActions(fee.getAllowedActions().stream() + .filter(action -> !Set.of("RETRY_PUBLISH", "RETRY_FUNDING_DELIVERY").contains(action)).toList()); + fee.setDeliveryStatus(null); fee.setDeliveryKind(null); fee.setDeliveryEventId(null); fee.setDeliveryErrorMessage(null); + if (eligible(fee, states.get(fee.getSourceFeeId()))) { + String key = eventKey(fee, states.get(fee.getSourceFeeId())); + if (key != null) keys.put(key, fee); + } + } + if (keys.isEmpty()) return; + for (var row : repository.events(keys.keySet())) { + var fee = keys.get(row.getEventKey()); + if (fee == null || !Objects.equals(fee.getCustomerId(), row.getCustomerId())) continue; + String kind = EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(row.getEventType()) ? "FEE" : "FUNDING"; + fee.setDeliveryKind(kind); fee.setDeliveryStatus(row.getStatus()); fee.setDeliveryErrorMessage(row.getLastError()); + try { fee.setDeliveryEventId(envelope(row, fee, states.get(fee.getSourceFeeId())).eventId()); } + catch (RuntimeException invalid) { continue; } // 损坏或串单事件不开放重试。 + if (retryable(row)) { + var actions = new ArrayList<>(fee.getAllowedActions() == null ? List.of() : fee.getAllowedActions()); + actions.add("FEE".equals(kind) ? "RETRY_PUBLISH" : "RETRY_FUNDING_DELIVERY"); + fee.setAllowedActions(List.copyOf(actions)); + } + } + } + + /** 锁定当前费用后校验版本及原事件;并发请求由事件行锁和唯一审计请求号防重。 */ + @Transactional + public void retry(long id, String operator, RetryDeliveryRequest request) { + var fee = fees.lockFee(id); + if (fee == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在"); + var funding = fees.lockFunding(id); + if (!eligible(fee, funding) || !Objects.equals(fee.getSourceVersion(), request.expectedVersion())) throw conflict("费用版本或状态已变化,请刷新"); + String key = eventKey(fee, funding); + var row = key == null ? null : repository.lock(key); + if (row == null) throw conflict("当前费用没有可重试的投递记录"); + var event = envelope(row, fee, funding); + if (!Objects.equals(event.eventId(), request.eventId())) throw conflict("投递记录已变化,请刷新"); + try { if (!UUID.fromString(request.idempotencyKey()).toString().equalsIgnoreCase(request.idempotencyKey())) throw new IllegalArgumentException(); } + catch (RuntimeException invalid) { throw conflict("重试请求编号无效"); } + var prior = repository.request(request.idempotencyKey()); + if (prior != null) { + if (!Objects.equals(prior.getEntryId(), String.valueOf(row.getId())) || !Objects.equals(prior.getEventId(), event.eventId()) + || !Objects.equals(prior.getPayloadHash(), event.payloadHash()) || !Objects.equals(prior.getOperatorName(), operator)) throw conflict("操作请求编号已用于不同重试参数"); + return; + } + if (!retryable(row)) throw conflict("仅允许重试失败事件,正在发送或已成功的不可重试"); + var now = LocalDateTime.now(ZoneOffset.UTC); + if (repository.requeue(row.getId(), now) != 1) throw conflict("投递状态已变化,请刷新"); + repository.insert(audit(row, event, request.idempotencyKey(), operator, "重试推送", now)); + } + + /** 人工资金失败重试与新指令在同一业务事务记录,不因回执延迟漏记操作。 */ + public void recordFundingRetry(OverseasFeeSource fee, OverseasFeeFunding funding, String operator, String key) { + String operation = operation(fee, funding); + String eventKey = "WALLET_COMMAND:" + fee.getCustomerId() + ":" + walletType(fee) + ":" + funding.getWalletBusinessNo() + ':' + operation + ':' + funding.getCommandVersion(); + var row = repository.event(eventKey); + if (row == null) throw conflict("资金重试指令缺少原始记录"); + var event = envelope(row, fee, funding); + String requestId = UUID.nameUUIDFromBytes((fee.getSourceFeeId() + ":" + key + ":" + operator).getBytes(StandardCharsets.UTF_8)).toString(); + String action = switch (operation) { case "FREEZE" -> "重试锁定"; case "RELEASE" -> "重试释放"; default -> "重试扣费"; }; + repository.insert(audit(row, event, requestId, operator, action, LocalDateTime.now(ZoneOffset.UTC))); + } + + public record RetryRecord(String requestId, LocalDateTime requestedAt, String operatorName, String action, String result, String errorMessage) { } + + /** 每次重试独立回看当次投递周期和资金结果,不把提交请求当成成功。 */ + public List history(long id) { + var fee = fees.current(id); + if (fee == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在"); + var rows = repository.history(fee.getCustomerId(), fee.getSourceType(), id); + Map until = new HashMap<>(); + List result = new ArrayList<>(); + for (var row : rows) { + // 余额费用不再要求费用推送,旧推送审计保留但不混入当前扣费重试记录。 + if ("RECEIVABLE".equals(fee.getDirection()) && fee.getWalletSettlement() != null + && EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(row.getEventType())) continue; + var upper = until.put(row.getEventId(), row.getRequestedAt()); + String outcome = row.getAttemptOutcome(); + String status = "SUBMITTED", error = null; + if (Set.of("APPLIED", "DUPLICATE", "STALE").contains(Objects.toString(outcome, ""))) status = "SUCCESS"; + // RETRY表示本次投递已失败,只是系统还会自动重试,不能一直显示已提交。 + else if (Set.of("RETRY", "DEAD", "LEASE_EXPIRED").contains(Objects.toString(outcome, "")) + || upper == null && ("DEAD".equals(row.getCurrentStatus()) + || Set.of("PENDING", "PROCESSING").contains(Objects.toString(row.getCurrentStatus(), "")) + && row.getCurrentError() != null && !row.getCurrentError().isBlank())) { status = "FAILED"; error = row.getCurrentError(); } + if (EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType())) { + status = "FAILED".equals(status) ? status : "SUBMITTED"; + if (row.getWalletResult() != null) try { + var envelope = json.readValue(row.getWalletResult(), EventEnvelope.class); + var completed = json.treeToValue(envelope.payload(), WalletCommandCompletedV1.class); + if (Objects.equals(completed.commandEventId(), row.getEventId()) && Objects.equals(String.valueOf(completed.customerId()), fee.getCustomerId())) { + status = "SUCCEEDED".equals(completed.result()) ? "SUCCESS" : "FAILED"; error = completed.errorMessage(); + } + } catch (Exception invalid) { status = "FAILED"; error = "重试结果读取失败"; } + } + result.add(new RetryRecord(row.getRequestId(), row.getRequestedAt(), row.getOperatorName(), row.getReason(), status, error)); + } + return result; + } + + /** 审计沿用原事件身份和摘要,保存实际操作人及失败快照。 */ + private OverseasFeeRetryAudit audit(LastlegEventOutbox row, EventEnvelope event, String key, String operator, String reason, LocalDateTime time) { + var value = new OverseasFeeRetryAudit(); value.setRequestId(key); value.setProducerTable("ll_event_outbox"); + value.setEntryId(String.valueOf(row.getId())); value.setEventId(event.eventId()); value.setEventType(event.eventType()); + value.setPayloadHash(event.payloadHash()); value.setOperatorName(operator); value.setReason(reason); + value.setPreviousFailures(row.getRetryCount() == null ? 0 : row.getRetryCount()); value.setPreviousError(row.getLastError()); value.setRequestedAt(time); + return value; + } + + /** 终结收费仍须完成原冻结/释放指令,重投不得重新打开扣费或费用发布。 */ + private boolean eligible(OverseasFeeSource fee, OverseasFeeFunding funding) { + if (funding != null && "WALLET".equals(funding.getCollectionMode()) + && ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) + && Set.of("FREEZE_PENDING", "RELEASE_PENDING").contains(Objects.toString(funding.getWalletStatus(), ""))) return true; + return "ACTIVE".equals(fee.getSourceStatus()) && !"REJECTED".equals(fee.getReviewStatus()) + && !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), "")); + } + + /** 已失败并等待重试时可立即重推;正在发送或已成功的事件不能抢占。 */ + private boolean retryable(LastlegEventOutbox row) { + return "DEAD".equals(row.getStatus()) || "PENDING".equals(row.getStatus()) + && row.getLastError() != null && !row.getLastError().isBlank(); + } + + /** 来源、版本、动作共同确定唯一事件,不按费用项目名称去重。 */ + private String eventKey(OverseasFeeSource fee, OverseasFeeFunding funding) { + // 实扣成功即完成,不再关联费用推送及其失败状态。 + if ("RECEIVABLE".equals(fee.getDirection()) && (fee.getWalletSettlement() != null + || funding != null && "WALLET".equals(funding.getCollectionMode()) && "SETTLED".equals(funding.getWalletStatus()))) return null; + if ("APPROVED".equals(fee.getReviewStatus()) && "PUBLISHED".equals(fee.getPublicationStatus())) + return EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:" + fee.getSourceType() + ':' + fee.getSourceFeeId() + + ':' + fee.getSourceVersion() + (fee.getSourceVersion() == 1 ? ":PUBLISHED" : ":ADJUSTED"); + if (funding == null || !"WALLET".equals(funding.getCollectionMode()) || funding.getWalletStatus() == null + || !funding.getWalletStatus().endsWith("_PENDING")) return null; + String operation = operation(fee, funding); + return "WALLET_COMMAND:" + fee.getCustomerId() + ":" + walletType(fee) + ":" + funding.getWalletBusinessNo() + + ':' + operation + ':' + funding.getCommandVersion(); + } + + /** 重试前核对原载荷哈希及业务归属,保留消费端已处理事件的唯一性。 */ + private EventEnvelope envelope(LastlegEventOutbox row, OverseasFeeSource fee, OverseasFeeFunding funding) { + try { + var event = json.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); + if (!Objects.equals(row.getCustomerId(), fee.getCustomerId()) || !Objects.equals(event.tenantId(), fee.getCustomerId()) + || !Objects.equals(event.correlationId(), row.getEventKey()) || !Objects.equals(event.eventType(), row.getEventType()) + || !EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) + throw conflict("原事件摘要或客户不一致"); + if (EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(event.eventType())) { + var value = json.treeToValue(event.payload(), BusinessFeeSourceChangedV1.class); + if (!Objects.equals(value.sourceFeeId(), String.valueOf(fee.getSourceFeeId())) + || !Objects.equals(value.sourceVersion(), fee.getSourceVersion()) || !Objects.equals(value.customerId(), fee.getCustomerId()) + || !Objects.equals(value.sourceType(), fee.getSourceType()) || !"LASTLEG".equals(value.sourceSystem()) + || !Objects.equals(event.aggregateId(), value.sourceFeeId()) || !Objects.equals(event.aggregateType(), value.sourceType()) + || event.aggregateVersion() != value.sourceVersion()) + throw conflict("推送记录与费用不一致"); + } else if (EventTypes.WALLET_COMMAND_REQUESTED.equals(event.eventType())) { + var value = json.treeToValue(event.payload(), WalletCommandRequestedV1.class); + if (funding == null || !Objects.equals(event.eventId(), funding.getCommandEventId()) + || !Objects.equals(value.businessNo(), funding.getWalletBusinessNo()) || !walletType(fee).equals(value.businessType()) + || !Objects.equals(String.valueOf(value.customerId()), fee.getCustomerId()) + || !Objects.equals(event.aggregateId(), funding.getWalletBusinessNo()) || !walletType(fee).equals(event.aggregateType()) + || event.aggregateVersion() != funding.getCommandVersion() + || !Objects.equals(value.operation(), operation(fee, funding))) throw conflict("资金记录与费用不一致"); + } else throw conflict("投递事件类型不属于费用"); + return event; + } catch (ResponseStatusException invalid) { throw invalid; } + catch (Exception invalid) { throw conflict("原投递记录无效,不能重试"); } + } + + /** 仓租沿原出库冻结身份,人工费用沿各自资金身份。 */ + private String walletType(OverseasFeeSource fee) { return StorageRentFeeWorkflow.isRent(fee) ? "STORAGE_RENT" : "OVERSEAS_MANUAL_FEE"; } + /** 仓租扣费使用审核后的实额结算指令。 */ + private String operation(OverseasFeeSource fee, OverseasFeeFunding funding) { + String operation = funding.getWalletStatus().replace("_PENDING", ""); + return StorageRentFeeWorkflow.isRent(fee) && "SETTLE".equals(operation) ? "SETTLE_ACTUAL" : operation; + } + /** 统一返回状态冲突,禁止通过重试重建费用。 */ + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index 286079b6a..c52ccb5a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -26,7 +26,9 @@ public class OverseasFeeOutboxService { @Resource private LastlegEventOutboxMapper mapper; @Resource private ObjectMapper objectMapper; + /** 仅推送账单费用;已实扣的海外应收保留本地明细,不再重复入财务结算池。 */ public void publish(OverseasFeeSource source, String action) { + if ("RECEIVABLE".equals(source.getDirection()) && source.getWalletSettlement() != null) return; BusinessFeeSourceChangedV1 unsigned = payload(source, null, null); String sourceHash = EventPayloadHash.sha256( EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java index 7f3df7aee..57f521c66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -98,10 +98,11 @@ public class OverseasFeeSourceService { return value; } + /** 自动确认仅推进新草稿,已驳回的原费用不得重新审核发布。 */ @Transactional public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) { OverseasFeeSource value = create(operatorId, key, request); - if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) { + if ("DRAFT".equals(value.getReviewStatus())) { value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit")); } if ("PENDING".equals(value.getReviewStatus())) { @@ -114,10 +115,48 @@ public class OverseasFeeSourceService { return value; } + /** 旧换标资金收尾复用既有来源,已发布账单不再生成第二笔应收。 */ + @Transactional + public OverseasFeeSource completeLegacyRelabelSettlement(String operator, String key, SaveRequest request, + com.baiyue.common.event.WalletSettlementReference settlement) { + if (!"RELABEL_ORDER".equals(request.businessType()) || !"RELABEL_SERVICE".equals(request.feeItemCode()) + || !"RECEIVABLE".equals(request.direction()) || !"CNY".equals(request.currency()) + || settlement == null || text(settlement.commandEventId()) == null || text(settlement.holdId()) == null + || text(settlement.ledgerId()) == null) throw bad("历史换标扣款引用不完整"); + var prior = listForBusiness(request.customerId(), "RELABEL_ORDER", request.businessId()).stream() + .filter(f -> "RELABEL_SERVICE".equals(f.getFeeItemCode()) && "RECEIVABLE".equals(f.getDirection()) + && "ACTIVE".equals(f.getSourceStatus())).findFirst(); + if (prior.isPresent()) { + var value = mapper.lockCurrent(prior.get().getSourceFeeId()); + if (value == null || value.getAmount() == null || value.getAmount().compareTo(request.amount()) != 0) + throw conflict("历史换标应收与扣款金额不一致,请核对"); + if (value.getWalletSettlement() != null && !value.getWalletSettlement().equals(settlement)) + throw conflict("历史换标已关联其他扣款,请核对"); + value.setWalletSettlement(settlement); value.setUpdatedBy(operator); value.setUpdatedAt(now()); mapper.updateById(value); + return value; + } + return publishSettled(operator,key,request,currencyConverter.convert(request.currency(),request.amount(),request.businessDate()),settlement); + } + private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion, com.baiyue.common.event.WalletSettlementReference settlement) { } - /** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */ + /** 实际出库先登记待审核仓租,沿用冻结汇率,不发账单或扣款。 */ + @Transactional + public OverseasFeeSource createPendingStorageRent(String key, SaveRequest request, + OverseasFeeCurrencyConverter.Conversion conversion) { + var prior = replay("STORAGE_RENT", "RENT_CREATE", key, request); + if (prior != null) return current(prior.getSourceFeeId()); + if (!"FEE_TYPE_077".equals(request.feeItemCode()) || !"RECEIVABLE".equals(request.direction())) + throw bad("仓租费用项目或方向无效"); + validate(request); businessGuard.requireOwned(request); + var fee = build(IdWorker.getId(), IdWorker.getId(), 1, "STORAGE_RENT", request, conversion); + fee.setReviewStatus("PENDING"); mapper.insert(fee); + command("STORAGE_RENT", "RENT_CREATE", key, request, fee.getSourceFeeId(), 1); + return fee; + } + + /** 实扣成功后登记海外费用,沿用已确认汇率和资金流水,不再推送账单结算。 */ @Transactional public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request, OverseasFeeCurrencyConverter.Conversion conversion, @@ -139,23 +178,24 @@ public class OverseasFeeSourceService { OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion); value.setWalletSettlement(settlement); value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED"); - value.setReviewerId(operatorId); value.setReviewedAt(now()); - value.setReviewReason("已核定业务费用扣款成功后发布"); + value.setReviewerId(operatorId); value.setReviewerName(operatorId); value.setReviewedAt(now()); + value.setReviewReason("业务费用已扣款"); mapper.insert(value); - outbox.publish(value, "PUBLISHED"); command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1); return value; } + /** 旧提交入口同样以驳回为终态,保留原审核记录。 */ @Transactional public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) { OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); requireNonProcurement(value.getSourceType(),value.getBusinessType()); - if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus())) - throw conflict("只有草稿或已驳回费用可以提交"); - value.setReviewStatus("PENDING"); value.setReviewReason(null); + // 驳回代表本条费用终结,录错后必须新建费用,不能覆盖原审核记录。 + if (!"DRAFT".equals(value.getReviewStatus())) + throw conflict("已驳回费用不能重新提交,请重新新增费用"); + value.setReviewStatus("PENDING"); value.setReviewReason(null); value.setReviewerId(null); value.setReviewerName(null); value.setReviewedAt(null); value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion()); return value; @@ -163,16 +203,26 @@ public class OverseasFeeSourceService { @Transactional public OverseasFeeSource review(long sourceFeeId, String operatorId, ReviewRequest request) { + return review(sourceFeeId, operatorId, operatorId, request); + } + + /** 兼容已有内部调用,并保存审核人展示名称快照。 */ + @Transactional + public OverseasFeeSource review(long sourceFeeId, String operatorId, String reviewerName, ReviewRequest request) { OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); requireNonProcurement(value.getSourceType(),value.getBusinessType()); if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())) throw conflict("只有待审核的有效费用可以审核"); + if (request.expectedVersion() != null && !Objects.equals(value.getSourceVersion(), request.expectedVersion())) + throw conflict("费用版本已变化,请刷新后重新审核"); boolean approved = "APPROVE".equals(request.decision()); + if (!approved && text(request.reason()) == null) throw bad("请填写驳回原因"); value.setReviewStatus(approved ? "APPROVED" : "REJECTED"); value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED"); - value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); value.setReviewedAt(now()); + value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); + value.setReviewerName(text(reviewerName) == null ? operatorId : text(reviewerName)); value.setReviewedAt(now()); value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED"); command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion()); @@ -228,6 +278,7 @@ public class OverseasFeeSourceService { return value; } + /** 保存费用及业务说明快照,和审核原因分别记录。 */ private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request, OverseasFeeCurrencyConverter.Conversion confirmedConversion) { OverseasFeeSource value = new OverseasFeeSource(); @@ -238,6 +289,7 @@ public class OverseasFeeSourceService { value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setInboundCode(text(request.inboundCode())); value.setContainerId(request.containerId()); value.setContainerBookingId(request.containerBookingId()); value.setContainerNo(text(request.containerNo())); value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim()); + value.setFeeRemark(text(request.feeRemark())); value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName())); value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); @@ -255,6 +307,7 @@ public class OverseasFeeSourceService { return value; } + /** 校验业务来源、费用项目与应收应付信息,不允许不同业务类型混挂。 */ private void validate(SaveRequest value) { String expected = switch (value.businessType()) { case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE"; @@ -262,7 +315,7 @@ public class OverseasFeeSourceService { case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE"; case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE"; case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE"; - case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE"; + case "WAREHOUSE_SERVICE", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "DISCARD_ORDER" -> "OVERSEAS_WAREHOUSE_FEE"; case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE"; case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE"; case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE"; @@ -270,6 +323,11 @@ public class OverseasFeeSourceService { default -> ""; }; if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配"); + // 采购、本土卡车和本土散板都是客户消费类业务:只能形成应收,并由钱包余额/授信扣款。 + if (Set.of("OVERSEAS_PROCUREMENT", "PROCUREMENT_FEE", "US_TRUCK", "LOCAL_LOOSEBOARD").contains(value.businessType()) + && !"RECEIVABLE".equals(value.direction())) { + throw bad("采购、本土卡车和本土散板费用只能登记应收"); + } if ("OVERSEAS_CONTAINER".equals(value.businessType()) && !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim())) throw bad("海外货柜费用项目不受支持"); @@ -282,9 +340,11 @@ public class OverseasFeeSourceService { boolean payable = "PAYABLE".equals(value.direction()); boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); if (!payable) { - if (value.supplierId() != null || text(value.supplierName()) != null || text(value.settlementMode()) != null + // 应收结算选择不代表供应商已付事实,两类信息仍严格隔离。 + if (value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) + || value.supplierId() != null || text(value.supplierName()) != null || text(value.supplierCategory()) != null || value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files) - throw bad("应收费用不得携带供应商或付款信息"); + throw bad("应收费用只能选择推送结算或余额扣费,不得携带付款信息"); return; } if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java new file mode 100644 index 000000000..5c35a9f28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java @@ -0,0 +1,528 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.common.event.WalletSettlementReference; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.*; +import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand; +import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository; +import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.baiyue.lastleg.wallet.WalletResultHandler; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 人工收费先锁款、再审核;只有 Finance 确认实扣后才发布已收费用。 */ +@Service +@RequiredArgsConstructor +public class OverseasManualFeeService implements WalletResultHandler { + @jakarta.annotation.Resource private StorageRentFeeWorkflow rentReview; + private static final String WALLET_BUSINESS = "OVERSEAS_MANUAL_FEE"; + private final OverseasFeeSourceService sources; + private final OverseasManualFeeRepository repository; + private final OverseasFeeBusinessRepository businesses; + private final FeeCatalogGateway catalog; + private final WalletCommandOutboxService wallet; + private final OverseasFeeOutboxService outbox; + private final ObjectMapper json; + private final OverseasFeeCancellation cancellation; + private final OverseasFeeCustomerNames customerNames; + private final OverseasFeeDeliveryService delivery; + + /** 分页筛选所有当前费用,包含自动仓租和资金处理进度。 */ + public PageResult page(int page, int size, String customerId, String businessType, + String businessNo, String feeItemCode, String reviewStatus, String walletStatus, String direction, String processingStatus) { + return page(page,size,customerId,businessType,businessNo,feeItemCode,reviewStatus,walletStatus,direction,processingStatus,false); + } + + /** 仓租专页按真实出库计租来源过滤,不能仅凭费用项目名称判断自动仓租。 */ + public PageResult page(int page, int size, String customerId, String businessType, + String businessNo, String feeItemCode, String reviewStatus, String walletStatus, String direction, String processingStatus, boolean storageRentOnly) { + if (page < 1 || size < 1 || size > 200) throw bad("分页参数无效"); + Map query = new HashMap<>(); + query.put("customerId", text(customerId)); query.put("businessType", text(businessType)); + query.put("businessNo", text(businessNo)); query.put("feeItemCode", text(feeItemCode)); + query.put("reviewStatus", text(reviewStatus)); query.put("walletStatus", text(walletStatus)); + if (direction != null && !List.of("RECEIVABLE", "PAYABLE").contains(direction)) throw bad("收支方向无效"); + query.put("direction", direction == null ? "RECEIVABLE" : direction); + if (text(processingStatus) != null && !Set.of("WAITING", "PROCESSING", "COMPLETED", "FAILED", "CLOSED").contains(processingStatus)) throw bad("费用状态无效"); + query.put("processingStatus", text(processingStatus)); + query.put("storageRentOnly",storageRentOnly); + query.put("offset", ((long) page - 1) * size); query.put("size", size); + long total = repository.count(query); + return new PageResult<>(describe(repository.page(query)), page, size, total, (total + size - 1) / size); + } + + /** 费用详情与列表使用一致的资金状态和可操作性。 */ + public OverseasFeeSource detail(long id) { return describe(List.of(required(repository.current(id)))).get(0); } + + /** 换标申请作为收费主业务后,履约详情仍需读取同客户的申请费用。 */ + public List relabelFeesForOrder(String customerId, long orderId) { + var business = businesses.relabelForOrder(orderId, customerId); + return business == null ? List.of() : sources.listForBusiness(customerId, "RELABEL_ORDER", business.businessId()); + } + + /** 批量补充资金状态和业务分类;客户显示名称以主档为准,不回写原收费凭据。 */ + public List describe(List fees) { + cancellation.describe(fees); + var funding = repository.funding(fees.stream().map(OverseasFeeSource::getSourceFeeId).toList()) + .stream().collect(Collectors.toMap(OverseasFeeFunding::getSourceFeeId, Function.identity())); + for (var fee : fees) applyDescription(fee, funding.get(fee.getSourceFeeId())); + var names = customerNames.resolve(fees.stream() + .map(fee -> new ContainerCustomerOption(fee.getCustomerId(), fee.getCustomerName())).toList()); + var relabelIds = new HashSet<>(businesses.relabelFeeIds(fees.stream() + .filter(fee -> "LASTLEG_ORDER".equals(fee.getBusinessType())) + .map(OverseasFeeSource::getSourceFeeId).distinct().toList())); + for (var fee : fees) { + fee.setDisplayBusinessType(relabelIds.contains(fee.getSourceFeeId()) ? "RELABEL_ORDER" : fee.getBusinessType()); + fee.setCustomerName(names.getOrDefault(fee.getCustomerId(), fee.getCustomerName())); + } + if(rentReview!=null)rentReview.describe(fees); + return fees; + } + + /** 客户范围来自真实业务,名称与费用详情统一取主档,不增加客户管理权限。 */ + public List customers() { + var customers = businesses.customers(); + var names = customerNames.resolve(customers); + return customers.stream().map(customer -> new ContainerCustomerOption(customer.customerId(), + names.getOrDefault(customer.customerId(), customer.customerName()))).toList(); + } + + /** 候选使用真实服务业务单和当前客户名称,不允许以车辆车次合并客户费用。 */ + public List businessOptions(String type, String customerId, String keyword) { + if (!Set.of("LASTLEG_ORDER", "RELABEL_ORDER", "WAREHOUSE_TRANSFER", "RETURN_ORDER", "US_TRUCK", "LOCAL_LOOSEBOARD", "DISCARD_ORDER", "OVERSEAS_INBOUND", "DELIVERY_ORDER").contains(type)) + throw bad("请选择实际服务业务单"); + var options = businesses.options(type, text(customerId), text(keyword)); + var names = customerNames.resolve(options.stream() + .map(option -> new ContainerCustomerOption(option.customerId(), option.customerName())).toList()); + return options.stream().map(option -> new BusinessOption(option.businessId(), option.businessType(), + option.businessNo(), option.customerId(), names.getOrDefault(option.customerId(), option.customerName()), + option.warehouseId(), option.orderId(), option.orderType())).toList(); + } + + /** 换标履约只作为关联订单,收费主业务必须使用真实换标申请。 */ + private SaveRequest resolveServiceBusiness(SaveRequest request) { + if (!"LASTLEG_ORDER".equals(request.businessType()) + || !"RELABEL_APPLICATION".equals(businesses.orderType(request.businessId(), request.customerId()))) return request; + if (!"OVERSEAS_ORDER_FEE".equals(request.sourceType()) + || businesses.owned("LASTLEG_ORDER", request.businessId(), request.customerId(), request.businessNo()) != 1 + || request.orderId() != null && !request.orderId().equals(request.businessId())) + throw bad("换标费用关联的履约订单不一致"); + var business = businesses.relabelForOrder(request.businessId(), request.customerId()); + if (business == null) throw bad("换标订单缺少真实换标申请关联,无法登记费用"); + return new SaveRequest(request.customerId(), request.customerName(), "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", + business.businessId(), business.businessNo(), request.businessId(), request.inboundId(), request.inboundCode(), + request.containerId(), request.containerBookingId(), request.containerNo(), request.warehouseId(), + request.feeItemCode(), request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(), + request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(), + request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(), request.currency(), + request.amount(), request.chargeableQuantity(), request.quantityUnit(), request.unitPrice(), request.periodStart(), request.periodEnd()); + } + + /** 创建立即进入待审核;应收异步冻结资金,头程附加费保留账单结算。 */ + @Transactional + public OverseasFeeSource create(String operator, String key, SaveRequest request) { + if (request.businessId() == null) throw bad("请选择本次服务对应的业务订单"); + request = resolveServiceBusiness(request); + requireManualItem(request.feeItemCode()); + catalog.requireActive(request.feeItemCode(), request.feeItemName(), request.direction()); + OverseasFeeSource fee = sources.create(operator, key, request); + fee = required(repository.lockFee(fee.getSourceFeeId())); + OverseasFeeFunding state = repository.lockFunding(fee.getSourceFeeId()); + if (state != null) return described(fee, state); + // 幂等重放先返回原结果;新费用在任何冻结前校验重复项目的业务说明。 + requireFeeRemark(fee); + requireNoLegacyDuplicate(fee); + state = initialize(fee); + fee.setReviewStatus("PENDING"); fee.setUpdatedAt(now()); repository.updateFee(fee); + if ("WALLET".equals(state.getCollectionMode())) send(fee, state, "FREEZE"); + return described(fee, state); + } + + /** 同业务同项目的旧自动收费需先核对,人工链自身允许合法分次收费。 */ + private void requireNoLegacyDuplicate(OverseasFeeSource fee) { + if (!"RECEIVABLE".equals(fee.getDirection())) return; + boolean relabelProject = Set.of("FEE_TYPE_071","RELABEL_SERVICE").contains(Objects.toString(fee.getFeeItemCode(),"")) + || Set.of("贴标费","换标费").contains(Objects.toString(fee.getFeeItemName(),"")); + boolean relabelBusiness = Set.of("RELABEL_ORDER","LASTLEG_ORDER").contains(fee.getBusinessType()); + if (relabelProject && relabelBusiness && repository.legacyRelabel(fee.getCustomerId(),fee.getBusinessType(),fee.getBusinessId())>0 + || repository.legacyProject(fee.getCustomerId(),fee.getBusinessType(),fee.getBusinessId(),fee.getFeeItemCode(),fee.getFeeItemName(),fee.getSourceFeeId())>0) + throw conflict("此项目已有旧版收费,请先核对原费用,避免重复收取"); + } + + /** 兼容历史人工来源时同样建立资金编排,禁止旧接口绕过锁款。 */ + private OverseasFeeFunding initialize(OverseasFeeSource fee) { + if (fee.getWalletSettlement() != null) throw conflict("已扣费用不能重新锁款"); + OverseasFeeFunding state = new OverseasFeeFunding(); + state.setSourceFeeId(fee.getSourceFeeId()); state.setCustomerId(fee.getCustomerId()); + boolean bill = bill(fee); + state.setCollectionMode(bill ? "BILL" : "WALLET"); state.setWalletStatus(bill ? "NOT_REQUIRED" : "FREEZE_PENDING"); + state.setCommandVersion(0L); state.setCommandHistory(new LinkedHashMap<>()); + state.setCreatedAt(now()); state.setUpdatedAt(now()); + repository.insertFunding(state); + return state; + } + + /** 草稿可提交,驳回费用终结并保留原审核记录。 */ + @Transactional + public OverseasFeeSource submit(long id, String operator, String key) { + OverseasFeeSource fee = required(repository.lockFee(id)); + requireManual(fee); + OverseasFeeFunding state = repository.lockFunding(id); + if (replayed(operator, "MANUAL_SUBMIT", key, id, id)) return described(fee, state); + boolean legacy = state == null; + if (!"ACTIVE".equals(fee.getSourceStatus()) || fee.getWalletSettlement() != null + || !("DRAFT".equals(fee.getReviewStatus()) || legacy && "PENDING".equals(fee.getReviewStatus()))) + throw conflict("当前费用不能提交"); + if (legacy) { requireNoLegacyDuplicate(fee); state = initialize(fee); } + if ("WALLET".equals(state.getCollectionMode())) { + if (!legacy && !Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus())) throw conflict("请先完成资金释放"); + send(fee, state, "FREEZE"); + } + fee.setReviewStatus("PENDING"); fee.setReviewReason(null); fee.setReviewerId(null); fee.setReviewerName(null); fee.setReviewedAt(null); touch(fee, operator); + record(operator, "MANUAL_SUBMIT", key, id, id, fee.getSourceVersion()); + return described(fee, state); + } + + /** 审核批准只发扣款指令,异步扣款失败时不得伪装为已收。 */ + @Transactional + public OverseasFeeSource review(long id, String operator, ReviewRequest request) { + return review(id, operator, operator, request); + } + + /** 保留审核人ID与展示名称,并按审核结果推进锁款或释放。 */ + @Transactional + public OverseasFeeSource review(long id, String operator, String reviewerName, ReviewRequest request) { + var current=repository.current(id); + if(current!=null&&StorageRentFeeWorkflow.isRent(current)) + return describe(List.of(rentReview.review(id,operator,reviewerName,request))).get(0); + OverseasFeeSource fee = required(repository.lockFee(id)); + requireManual(fee); + OverseasFeeFunding state = repository.lockFunding(id); + if (state == null) { + if (!bill(fee)) throw conflict("请先重新提交费用并锁定资金"); + requireLegacyCommandOwner(operator, "REVIEW", request.idempotencyKey(), id); + return described(sources.review(id, operator, reviewerName, request), null); + } + if (replayed(operator, "MANUAL_REVIEW", request.idempotencyKey(), id, request)) return described(fee, state); + if (request.expectedVersion() != null && !Objects.equals(fee.getSourceVersion(), request.expectedVersion())) + throw conflict("费用版本已变化,请刷新后重新审核"); + if (!"ACTIVE".equals(fee.getSourceStatus()) || !"PENDING".equals(fee.getReviewStatus())) throw conflict("只有待审核费用可以审核"); + if (!Set.of("APPROVE", "REJECT").contains(request.decision())) throw bad("审核操作无效"); + boolean approved = "APPROVE".equals(request.decision()); + if (!approved && text(request.reason()) == null) throw bad("请填写驳回原因"); + boolean walletCollection = "WALLET".equals(state.getCollectionMode()); + if (approved && walletCollection && !"FROZEN".equals(state.getWalletStatus())) throw conflict("费用资金尚未锁定,暂不能通过审核"); + fee.setReviewStatus(approved ? "APPROVED" : "REJECTED"); + fee.setPublicationStatus(approved && !walletCollection ? "PUBLISHED" : "UNPUBLISHED"); + fee.setReviewerId(operator); fee.setReviewerName(text(reviewerName) == null ? operator : text(reviewerName)); + fee.setReviewedAt(now()); fee.setReviewReason(text(request.reason())); touch(fee, operator); + if (approved && walletCollection) send(fee, state, "SETTLE"); + else if (approved) outbox.publish(fee, fee.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED"); + else if ("FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE"); + record(operator, "MANUAL_REVIEW", request.idempotencyKey(), id, request, fee.getSourceVersion()); + return described(fee, state); + } + + /** 未收取费用可取消;冻结在途时等待匹配回执后自动释放。 */ + @Transactional + public OverseasFeeSource cancel(long id, String operator, String key) { + OverseasFeeSource fee = required(repository.lockFee(id)); + requireManual(fee); + requireNotRejected(fee); + OverseasFeeFunding state = repository.lockFunding(id); + if (state == null) throw conflict("该来源请通过原业务处理"); + if (replayed(operator, "MANUAL_CANCEL", key, id, id)) return described(fee, state); + if (fee.getWalletSettlement() != null || "APPROVED".equals(fee.getReviewStatus()) || "PUBLISHED".equals(fee.getPublicationStatus())) + throw conflict("费用已审核或已收取,不能取消"); + fee.setSourceStatus("VOIDED"); touch(fee, operator); + if ("FROZEN".equals(state.getWalletStatus())) send(fee, state, "RELEASE"); + record(operator, "MANUAL_CANCEL", key, id, id, fee.getSourceVersion()); + return described(fee, state); + } + + /** 失败指令显式重试;不改变已经成功的资金事实。 */ + @Transactional + public OverseasFeeSource retry(long id, String operator, String key) { + return retry(id, operator, operator, key); + } + + /** 重试操作人与新资金指令同事务留痕,重复请求不增加重试记录。 */ + @Transactional + public OverseasFeeSource retry(long id, String operator, String operatorName, String key) { + OverseasFeeSource fee = required(repository.lockFee(id)); + requireManual(fee); + OverseasFeeFunding state = repository.lockFunding(id); + if (state == null || !"WALLET".equals(state.getCollectionMode())) throw conflict("该费用无需处理余额"); + if (replayed(operator, "MANUAL_FUNDING_RETRY", key, id, id)) return described(fee, state); + String operation = switch (state.getWalletStatus()) { + case "FREEZE_FAILED" -> "FREEZE"; + case "SETTLE_FAILED" -> "SETTLE"; + case "RELEASE_FAILED" -> "RELEASE"; + default -> throw conflict("当前资金状态无需重试"); + }; + if ("FREEZE".equals(operation) && (!"ACTIVE".equals(fee.getSourceStatus()) || !"PENDING".equals(fee.getReviewStatus()))) + throw conflict("已驳回或取消的费用不能继续锁款"); + send(fee, state, operation); + delivery.recordFundingRetry(fee, state, operatorName, key); + record(operator, "MANUAL_FUNDING_RETRY", key, id, id, fee.getSourceVersion()); + return described(fee, state); + } + + /** 只接收人工费用独立资金身份,不接管换标和采购原冻结记录。 */ + @Override + public boolean supports(String businessType) { return WALLET_BUSINESS.equals(businessType); } + + /** 校验客户、业务身份、指令及金额;重复回执不能重复扣款或发布。 */ + @Override + @Transactional + public void handle(WalletCommandCompletedV1 result) { + long id; + try { id = Long.parseLong(result.businessNo().split("-", 2)[0]); } + catch (RuntimeException exception) { throw conflict("费用资金业务编号无效"); } + OverseasFeeSource fee = required(repository.lockFee(id)); + OverseasFeeFunding state = repository.lockFunding(id); + if (state == null || !supports(result.businessType()) || !fee.getCustomerId().equals(String.valueOf(result.customerId()))) + throw conflict("资金结果与费用客户不匹配"); + requireCnySnapshot(fee); + if (!"WALLET".equals(state.getCollectionMode()) || result.result() == null || !Set.of("SUCCEEDED", "FAILED").contains(result.result()) + || result.completedAt() == null || result.operation() == null || !Set.of("FREEZE", "SETTLE", "RELEASE").contains(result.operation())) + throw conflict("费用资金结果字段不完整"); + String expected = state.getCommandHistory().get(result.commandEventId()); + String completed = result.operation() + ':' + hash(result); + if (completed.equals(expected)) return; + if (!Objects.equals(state.getCommandEventId(), result.commandEventId()) + || !Objects.equals(state.getWalletBusinessNo(), result.businessNo()) + || !Objects.equals(state.getWalletStatus(), result.operation() + "_PENDING") + || !Objects.equals(expected, result.operation())) throw conflict("资金结果与当前费用指令不匹配"); + boolean succeeded = "SUCCEEDED".equals(result.result()); + if (succeeded && (result.amount() == null || fee.getAmount().compareTo(result.amount()) != 0 + || text(result.walletHoldId()) == null)) throw conflict("资金成功结果金额或冻结引用不匹配"); + if (succeeded && !"FREEZE".equals(result.operation()) && !Objects.equals(state.getWalletHoldId(), result.walletHoldId())) + throw conflict("资金结果的冻结引用与原锁款不匹配"); + if (succeeded && "SETTLE".equals(result.operation()) + && (!"APPROVED".equals(fee.getReviewStatus()) || !"ACTIVE".equals(fee.getSourceStatus()) || !"UNPUBLISHED".equals(fee.getPublicationStatus()))) + throw conflict("当前费用不能接收扣款成功结果"); + state.getCommandHistory().put(result.commandEventId(), completed); + String error = text(result.errorMessage()); + state.setErrorMessage(succeeded ? null : error == null ? "资金处理失败" : error.substring(0, Math.min(500, error.length()))); + if (!succeeded) state.setWalletStatus(result.operation() + "_FAILED"); + else { + state.setWalletHoldId(result.walletHoldId()); + switch (result.operation()) { + case "FREEZE" -> { + if (!"FROZEN".equals(result.holdStatus())) throw conflict("冻结回执状态无效"); + state.setWalletStatus("FROZEN"); + if ("VOIDED".equals(fee.getSourceStatus()) || "REJECTED".equals(fee.getReviewStatus())) send(fee, state, "RELEASE"); + } + case "SETTLE" -> { + if (!"SETTLED".equals(result.holdStatus()) || text(result.walletLedgerId()) == null) + throw conflict("扣款回执缺少已扣流水"); + state.setWalletStatus("SETTLED"); state.setWalletLedgerId(result.walletLedgerId()); + fee.setWalletSettlement(new WalletSettlementReference(result.commandEventId(), result.walletHoldId(), result.walletLedgerId(), WALLET_BUSINESS, state.getWalletBusinessNo())); + fee.setPublicationStatus("PUBLISHED"); touch(fee, fee.getUpdatedBy()); + // 余额及授信实扣即完成,只保留海外费用和资金流水引用,不再进入账单结算。 + } + case "RELEASE" -> { + if (!"RELEASED".equals(result.holdStatus())) throw conflict("释放回执状态无效"); + state.setWalletStatus("RELEASED"); + } + default -> throw conflict("费用资金操作不受支持"); + } + } + state.setUpdatedAt(now()); repository.updateFunding(state); + } + + /** 已扣款及自动仓租不允许从通用调价入口篡改。 */ + @Transactional + public OverseasFeeSource adjust(long id, String operator, AdjustRequest request) { + OverseasFeeSource fee = required(repository.lockFee(id)); + requireManual(fee); + requireNotRejected(fee); + OverseasFeeFunding state = repository.lockFunding(id); + if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode())) + throw conflict("余额费用请取消重建;已扣费用请通过退款处理"); + if (!bill(fee)) throw conflict("历史海外费用请先完成原账单作废,再重新创建余额费用"); + requireManualItem(request.fee().feeItemCode()); + catalog.requireActive(request.fee().feeItemCode(), request.fee().feeItemName(), request.fee().direction()); + if (!Objects.equals(fee.getCustomerId(), request.fee().customerId()) || !Objects.equals(fee.getBusinessType(), request.fee().businessType()) + || !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction())) + throw conflict("调整不能更换客户、业务订单或收支方向"); + requireLegacyCommandOwner(operator, "ADJUST", request.idempotencyKey(), id); + return described(sources.adjust(id, operator, request), state); + } + + /** 已扣费用不可作废,账单费用仍等待 Finance 确认作废。 */ + @Transactional + public OverseasFeeSource voidSource(long id, String operator, VoidRequest request) { + OverseasFeeSource fee = required(repository.lockFee(id)); + requireManual(fee); + requireNotRejected(fee); + OverseasFeeFunding state = repository.lockFunding(id); + if (fee.getWalletSettlement() != null || state != null && "WALLET".equals(state.getCollectionMode())) + throw conflict("已扣费用请通过退款处理,未扣费用请取消"); + requireLegacyCommandOwner(operator, "VOID", request.idempotencyKey(), id); + return described(sources.voidSource(id, operator, request), state); + } + + /** 资金指令与费用变更同事务写 Outbox,冻结与业务操作互不抢用身份。 */ + private void send(OverseasFeeSource fee, OverseasFeeFunding state, String operation) { + requireCnySnapshot(fee); + long version = state.getCommandVersion() + 1; + if ("FREEZE".equals(operation)) state.setWalletBusinessNo(fee.getSourceFeeId() + "-" + version); + String event = wallet.publish(operation, Long.parseLong(fee.getCustomerId()), WALLET_BUSINESS, + state.getWalletBusinessNo(), "FREEZE".equals(operation) ? fee.getAmount() : null, + "海外费用:" + fee.getBusinessNo() + " / " + fee.getFeeItemName(), version); + state.setCommandVersion(version); state.setCommandEventId(event); state.setWalletStatus(operation + "_PENDING"); + state.setErrorMessage(null); state.getCommandHistory().put(event, operation); + state.setUpdatedAt(now()); repository.updateFunding(state); + } + + /** 可操作性只描述业务资格,最终权限仍由 Controller 校验。 */ + private void applyDescription(OverseasFeeSource fee, OverseasFeeFunding state) { + if (state != null) { + fee.setCollectionMode(state.getCollectionMode()); fee.setWalletStatus(state.getWalletStatus()); fee.setWalletErrorMessage(state.getErrorMessage()); + } else if (fee.getWalletSettlement() != null) { + fee.setCollectionMode("WALLET"); fee.setWalletStatus("SETTLED"); + } else if ("PUBLISHED".equals(fee.getPublicationStatus()) || bill(fee)) { + fee.setCollectionMode("BILL"); fee.setWalletStatus("NOT_REQUIRED"); + } else { + fee.setCollectionMode("WALLET"); + } + List actions = new ArrayList<>(); + if ("ACTIVE".equals(fee.getSourceStatus()) && fee.getWalletSettlement() == null && !procurement(fee) + && !Set.of("PENDING_FINANCE", "FINANCE_ATTENTION").contains(Objects.toString(fee.getVoidStatus(), ""))) { + if ("PENDING".equals(fee.getReviewStatus())) { + actions.add("REJECT"); + if (state == null && bill(fee) || state != null && ("BILL".equals(state.getCollectionMode()) || "FROZEN".equals(state.getWalletStatus()))) actions.add("APPROVE"); + if (state == null && !bill(fee)) { actions.remove("REJECT"); actions.add("SUBMIT"); } + } + if (state != null && "FREEZE_FAILED".equals(state.getWalletStatus()) && "PENDING".equals(fee.getReviewStatus())) actions.add("RETRY_FREEZE"); + if (state != null && "SETTLE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_SETTLE"); + if ("DRAFT".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode()) + || Set.of("RELEASED", "FREEZE_FAILED").contains(state.getWalletStatus()))) actions.add("SUBMIT"); + if ("APPROVED".equals(fee.getReviewStatus()) && (state == null || "BILL".equals(state.getCollectionMode()))) { + if (bill(fee)) actions.add("ADJUST"); actions.add("VOID"); + } + } + if (state != null && "RELEASE_FAILED".equals(state.getWalletStatus())) actions.add("RETRY_RELEASE"); + // 驳回不能重开收费,但资金释放失败仍须向操作端提供补偿入口。 + if ("REJECTED".equals(fee.getReviewStatus())) actions.removeIf(action -> !"RETRY_RELEASE".equals(action)); + fee.setAllowedActions(List.copyOf(actions)); + if(StorageRentFeeWorkflow.isRent(fee)){ + // 仓租沿原出库冻结审核,不开放取消重建、通用调价或人工资金身份。 + var rentActions=new ArrayList(); + if(state!=null&&"ACTIVE".equals(fee.getSourceStatus())&&"PENDING".equals(fee.getReviewStatus())&&"FROZEN".equals(state.getWalletStatus())) + rentActions.addAll(List.of("APPROVE","REJECT","ADJUST_RENT")); + fee.setAllowedActions(List.copyOf(rentActions)); + } + } + + /** 重复作业或补收必须解释用途,普通首次录费可不填说明。 */ + private void requireFeeRemark(OverseasFeeSource fee) { + if (text(fee.getFeeRemark()) == null && repository.hasSameProject(fee)) + throw bad("此单已有相同费用项目,请填写费用说明后继续添加"); + } + + /** 禁止取消或改写驳回记录,录错后应新建费用。 */ + private void requireNotRejected(OverseasFeeSource fee) { + if ("REJECTED".equals(fee.getReviewStatus())) throw conflict("已驳回费用不能操作,请重新新增费用"); + } + + /** 只有实际头程服务订单的附加费走账单,海外新工单始终独立扣款。 */ + private boolean bill(OverseasFeeSource fee) { + if ("RECEIVABLE".equals(fee.getDirection()) && "WALLET".equals(fee.getSettlementMode())) return false; + if ("RECEIVABLE".equals(fee.getDirection()) && "BILL".equals(fee.getSettlementMode())) return true; + return !"RECEIVABLE".equals(fee.getDirection()) || "LASTLEG_ORDER".equals(fee.getBusinessType()) + && "FIRST_LEG".equals(businesses.orderType(fee.getBusinessId(), fee.getCustomerId())); + } + + /** 采购已有专用锁款与履约结算,不允许通用费用入口重复收取。 */ + private void requireManual(OverseasFeeSource fee) { + if(StorageRentFeeWorkflow.isRent(fee))throw conflict("自动仓租请通过仓租审核或调整入口处理"); + if (procurement(fee)) throw conflict("采购费用请通过采购履约或财务节点处理"); + } + + /** 识别采购来源及其工单类型,兼容历史费用数据。 */ + private boolean procurement(OverseasFeeSource fee) { + return "OVERSEAS_PROCUREMENT_FEE".equals(fee.getSourceType()) || "OVERSEAS_PROCUREMENT".equals(fee.getBusinessType()) + || "PROCUREMENT_FEE".equals(fee.getBusinessType()); + } + + /** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */ + private void requireManualItem(String code) { + if ("FEE_TYPE_077".equals(code)) throw bad("仓租自动计算,请勿手动添加"); + } + + /** 钱包只收人民币;核对创建时保存的原币和月度汇率快照,不重新取价。 */ + private void requireCnySnapshot(OverseasFeeSource fee) { + if (!"RECEIVABLE".equals(fee.getDirection()) || !"CNY".equals(fee.getCurrency()) || fee.getAmount() == null + || fee.getAmount().signum() <= 0 || fee.getOriginalAmount() == null || fee.getOriginalAmount().signum() <= 0 + || fee.getExchangeRate() == null || fee.getExchangeRate().signum() <= 0 || text(fee.getExchangeRateMonth()) == null + || fee.getBusinessDate() == null || !fee.getBusinessDate().toString().substring(0, 7).equals(fee.getExchangeRateMonth()) + || fee.getOriginalCurrency() == null || !Set.of("CNY", "USD").contains(fee.getOriginalCurrency()) + || "CNY".equals(fee.getOriginalCurrency()) && fee.getExchangeRate().compareTo(BigDecimal.ONE) != 0 + || fee.getOriginalAmount().multiply(fee.getExchangeRate()).setScale(2, RoundingMode.HALF_UP).compareTo(fee.getAmount()) != 0) + throw conflict("费用人民币结算快照无效"); + } + + /** 为单条变更结果补充与列表一致的操作资格。 */ + private OverseasFeeSource described(OverseasFeeSource fee, OverseasFeeFunding state) { applyDescription(fee, state); return fee; } + /** 兼容旧载荷哈希,同时禁止跨费用复用旧审核、调整或作废操作标识。 */ + private void requireLegacyCommandOwner(String operator, String action, String key, long id) { + if (text(key) == null || key.length() > 128) throw bad("操作标识无效"); + var existing = repository.command(operator, action, key.trim()); + if (existing != null && !Objects.equals(existing.getSourceFeeId(), id)) + throw conflict("操作标识已被其他费用使用"); + } + /** 核对重复操作的业务ID及载荷,禁止用相同键操作其他费用。 */ + private boolean replayed(String operator, String action, String key, long id, Object payload) { + if (text(key) == null || key.length() > 128) throw bad("操作标识无效"); + OverseasFeeCommand existing = repository.command(operator, action, key.trim()); + if (existing != null && (!Objects.equals(existing.getSourceFeeId(), id) || !existing.getRequestHash().equals(hash(List.of(id, payload))))) + throw conflict("操作标识已被其他请求使用"); + return existing != null; + } + /** 本地变更完成后保存操作幂等记录。 */ + private void record(String operator, String action, String key, long id, Object payload, int version) { + OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator); + value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(List.of(id, payload))); + value.setSourceFeeId(id); value.setSourceVersion(version); value.setCreatedAt(now()); repository.insertCommand(value); + } + /** 状态变更保留操作人及时间。 */ + private void touch(OverseasFeeSource fee, String operator) { fee.setUpdatedBy(operator); fee.setUpdatedAt(now()); repository.updateFee(fee); } + /** 不暴露不存在的费用。 */ + private OverseasFeeSource required(OverseasFeeSource value) { if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "费用不存在"); return value; } + /** 固定载荷哈希用于操作与结果防重。 */ + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException("费用操作快照生成失败", exception); } + } + /** 统一使用 UTC 保存业务时刻。 */ + private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } + /** 统一可选文本。 */ + private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); } + /** 返回参数错误。 */ + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + /** 返回业务状态冲突。 */ + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/StorageRentFeeWorkflow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/StorageRentFeeWorkflow.java new file mode 100644 index 000000000..d3efd615c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/StorageRentFeeWorkflow.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.financefee.service; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest; +import java.util.List; +/** 费用域通过端口委托仓租审核,避免依赖仓租实现。 */ +public interface StorageRentFeeWorkflow { + /** 仓租来源标记,写操作仍核对持久化归属。 */ + static boolean isRent(OverseasFeeSource fee) { return "STORAGE_RENT".equals(fee.getCreatedBy()) && "FEE_TYPE_077".equals(fee.getFeeItemCode()); } + /** 附加原计租批次和实际出库明细。 */ + void describe(List rows); + /** 按原冻结审核仓租,批准后申请实际扣款。 */ + OverseasFeeSource review(long id,String operator,String name,ReviewRequest request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index d28ad8587..1c1106b18 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -39,6 +39,8 @@ import java.util.stream.Collectors; /** Converts final PDA package counts into inbound-order/pallet inventory. */ @Service public class InboundStockReceiptService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderDetailMapper orderDetails; @Resource private InboundStockReceiptMapper receipts; @@ -213,6 +215,7 @@ public class InboundStockReceiptService { } } + /** 真实库存入账后同步创建不可重置库龄的原计租批次。 */ private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) { @@ -286,6 +289,8 @@ public class InboundStockReceiptService { receipt.setReceivedAt(receivedAt); receipt.setCreatedAt(receivedAt); receipts.insert(receipt); + // 实际实收与原计租批次同事务落地,重复凭证不增加租金基数。 + storageRent.registerInbound(receipt, order); return receipt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java index e491cbd24..8088d884c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java @@ -1,9 +1,6 @@ package com.baiyue.lastleg.inboundplan.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; -import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; -import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.JdbcTemplate; @@ -27,8 +24,6 @@ public class DestuffingPalletCostService { static final String RECEIVED_PALLET_IDS_SQL = "SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL AND pp.actual_package_count>0 AND pp.actual_volume_cbm>0 ORDER BY pp.warehouse_pallet_id"; private final JdbcTemplate jdbc; private final PalletPhysicalGateway pallets; - private final OverseasFeeSourceMapper feeSources; - private final OverseasFeeOutboxService feeOutbox; public List readyBookingIds() { return jdbc.queryForList(READY_BOOKINGS_SQL, String.class); @@ -59,10 +54,9 @@ public class DestuffingPalletCostService { for (int i=0; i 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null); - v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator); - v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v; - } - private static Long parseLong(String value){try{return Long.parseLong(value);}catch(Exception ignored){return null;}} private record CustomerVolume(String customerId, BigDecimal volume){} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java index 80af7c406..5f6c49113 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -60,9 +60,9 @@ public class PlatformOverseasProcurementController { @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") @Operation(summary = "List procurement warehouse options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) public ApiResponse> warehouseOptions( - @AuthenticationPrincipal AuthenticatedUser user) { + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String procurementDirection) { requirePlatform(user); - return ApiResponse.success(service.warehouseOptions()); + return ApiResponse.success(service.warehouseOptions(procurementDirection)); } @GetMapping("/{orderNo}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 2760b13d3..2441dae13 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -28,7 +28,15 @@ public final class OverseasProcurementRequests { @NotBlank @Size(max = 128) String contactName, @NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone, @Size(max = 1000) String remark, - @NotBlank @Size(max = 255) String idempotencyKey) { + @NotBlank @Size(max = 255) String idempotencyKey, + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + @jakarta.validation.constraints.Pattern(regexp="OVERSEAS_TO_DOMESTIC|DOMESTIC_TO_OVERSEAS") String procurementDirection) { + public Create(String customerName, String purchaseCategory, String productName, String productSpecification, + String productUrl, Long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, + String receivingAddress, String contactName, String contactPhone, String remark, String idempotencyKey) { + this(customerName, purchaseCategory, productName, productSpecification, productUrl, purchaseQuantity, + budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, remark, idempotencyKey, null); + } @com.fasterxml.jackson.annotation.JsonAnySetter public void rejectUnknownField(String name, JsonNode value) { throw new IllegalArgumentException("采购下单仅支持单商品、单规格及一次购买批次,不支持扩展字段:" + name); @@ -57,7 +65,13 @@ public final class OverseasProcurementRequests { @NotBlank String decision, @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark, - @Positive Long purchaseWarehouseId) {} + @Positive Long purchaseWarehouseId, + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + @jakarta.validation.constraints.Pattern(regexp="OVERSEAS_TO_DOMESTIC|DOMESTIC_TO_OVERSEAS") String procurementDirection) { + public Review(Long version, String decision, String idempotencyKey, String remark, Long purchaseWarehouseId) { + this(version, decision, idempotencyKey, remark, purchaseWarehouseId, null); + } + } public record Update( @NotNull @Min(0) Long version, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index 685c764d8..d6ee2c223 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -12,7 +12,7 @@ public final class OverseasProcurementResponses { String purchaseCategory, String productName, String productSpecification, long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, String purchaseWarehouseId, String purchaseWarehouseName, - String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, String procurementDirection) {} public record CustomerOption(String customerId, String customerName) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index 4cf2bce87..c571d1909 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -17,6 +17,7 @@ public class OverseasProcurementOrder { private String customerId; private String customerName; private String orderNo; + private String procurementDirection; private String purchaseCategory; private String productName; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index f47a55f0b..2075a6ebd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -50,12 +50,13 @@ public class OverseasProcurementService { public static final String WAIT_PURCHASE = "WAIT_PURCHASE"; public static final String REJECTED = "REJECTED"; private static final Set STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT", - "OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "INTERNATIONAL_TRANSIT", - "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED"); + "OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "WAIT_OVERSEAS_SHIPMENT", "INTERNATIONAL_TRANSIT", + "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "WAIT_CUSTOMER_SHIPMENT", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED"); @Resource private OverseasProcurementOrderMapper orders; @Resource private OverseasProcurementStatusHistoryMapper histories; @Resource private ProcurementWarehouseDirectory warehouseDirectory; + @Resource private com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository; @Resource private ObjectMapper objectMapper; @Resource private OverseasProcurementFileMapper procurementFiles; @Resource private OverseasProcurementFeeMapper procurementFees; @@ -76,6 +77,7 @@ public class OverseasProcurementService { order.setCustomerId(customerId); order.setCustomerName(trim(request.customerName())); order.setOrderNo(orderNoGenerator.next("CG")); + order.setProcurementDirection(ProcurementDirection.normalize(request.procurementDirection())); order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim()); order.setProductName(request.productName().trim()); order.setProductSpecification(trim(request.productSpecification())); @@ -115,8 +117,12 @@ public class OverseasProcurementService { .toList(); } - public List warehouseOptions() { - return warehouseDirectory.options().stream() + public List warehouseOptions() { return warehouseOptions(null); } + + /** 按采购方向读取权威起运收货仓目录。 */ + public List warehouseOptions(String direction) { + return (ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction)) + ? warehouseDirectory.options(direction) : warehouseDirectory.options()).stream() .map(value -> new OverseasProcurementResponses.WarehouseOption(String.valueOf(value.warehouseId()), value.warehouseCode(), value.warehouseName(), value.countryCode())) .toList(); @@ -226,12 +232,20 @@ public class OverseasProcurementService { } if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); + String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection()) + : ProcurementDirection.normalize(request.procurementDirection()); + if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection())) && directionRepository.hasStarted(order.getId())) { + throw conflict("本单已有购买或收货事实,不能切换采购方向"); + } + Map before = reviewSnapshot(order); if (WAIT_PURCHASE.equals(next)) { - if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择有效海外收货仓"); - var warehouse = warehouseDirectory.required(request.purchaseWarehouseId()); + if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择与采购方向匹配的收货仓"); + var warehouse = ProcurementDirection.EXPORT.equals(direction) + ? warehouseDirectory.required(request.purchaseWarehouseId(), direction) : warehouseDirectory.required(request.purchaseWarehouseId()); order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId())); order.setPurchaseWarehouseName(warehouse.warehouseName()); } + order.setProcurementDirection(direction); LocalDateTime now = LocalDateTime.now(); order.setStatus(next); order.setReviewedBy(operatorId); @@ -242,7 +256,7 @@ public class OverseasProcurementService { touch(order, now); orders.updateById(order); history(order, "REVIEW", PENDING_REVIEW, next, operatorId, - key, null, request.remark(), now); + key, changeDetail(before, reviewSnapshot(order)), request.remark(), now); return detail(customerId, orderNo); } @@ -282,7 +296,7 @@ public class OverseasProcurementService { order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), - displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); + displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), ProcurementDirection.normalize(order.getProcurementDirection())); } private void history(OverseasProcurementOrder order, String operationType, String from, String to, @@ -303,6 +317,15 @@ public class OverseasProcurementService { histories.insert(history); } + /** 审核流水记录采购方向和收货仓变更,不仅记录状态。 */ + private Map reviewSnapshot(OverseasProcurementOrder order) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("procurementDirection", ProcurementDirection.normalize(order.getProcurementDirection())); + snapshot.put("purchaseWarehouseId", order.getPurchaseWarehouseId()); + snapshot.put("purchaseWarehouseName", order.getPurchaseWarehouseName()); + return snapshot; + } + private void validateQuery(OverseasProcurementRequests.Query query) { if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) { throw badRequest("Unsupported procurement status"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java index 678b5e980..d27546287 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java @@ -47,7 +47,7 @@ public class ProcurementControlService { if(lifecycle.replay(order,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request))return snapshot(order); version(order,request.version());proof(order,request.voucherFileId());var flow=lifecycle.flow(order.getId()); if(flow.get("actual_warehouse_id")==null)throw conflict("尚无实际收货仓,请修改计划仓"); - var warehouse=warehouses.required(request.warehouseId()); + var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId())); long old=((Number)flow.get("actual_warehouse_id")).longValue();if(old==request.warehouseId())throw bad("更正仓库与原仓库相同"); Integer downstream=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_carton WHERE order_id=?",Integer.class,order.getId()); boolean appendOnly=downstream>0 || flow.get("overseas_shipped_at")!=null || Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED").contains(order.getStatus()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java index 9f77bdc76..6c75ccd80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java @@ -124,7 +124,7 @@ public class ProcurementFinanceService implements WalletResultHandler { fee.setFeeNo(documentNumbers.next("PF")); fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1); fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code); - fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label()); + fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : ProcurementDirection.display(request.purpose().label(), order.getProcurementDirection())); fee.setOriginalCurrency(conversion.originalCurrency()); fee.setOriginalAmount(conversion.originalAmount()); fee.setExchangeRate(conversion.exchangeRate()); fee.setExchangeRateMonth(conversion.exchangeRateMonth()); fee.setAmount(conversion.settlementAmount()); fee.setCurrency("CNY"); fee.setBusinessDate(request.businessDate()); @@ -450,7 +450,7 @@ public class ProcurementFinanceService implements WalletResultHandler { private void publishSource(OverseasProcurementOrder order, OverseasProcurementFee fee) { var request = new OverseasFeeSourceDtos.SaveRequest(fee.getCustomerId(), order.getCustomerName(), "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getWalletBusinessId(), fee.getWalletBusinessNo(), - order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", + order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), null, "RECEIVABLE", null, null, null, null, null, null, null, List.of(), fee.getBusinessDate(), fee.getOriginalCurrency(), fee.getOriginalAmount(), BigDecimal.ONE, "ITEM", fee.getOriginalAmount(), null, null); var source = sources.publishSettled(fee.getCreatedBy(), "PROCUREMENT:" + fee.getFeeNo(), request, @@ -460,14 +460,13 @@ public class ProcurementFinanceService implements WalletResultHandler { fee.setSourceFeeId(source.getSourceFeeId()); } + /** 余额实扣并登记本地费用来源后即可履约,无需等待财务账单推送。 */ public boolean sourceLinked(OverseasProcurementFee fee) { if(fee==null || fee.getWalletStatus()==null)return false; if (Set.of("ZERO", "NOT_APPLICABLE", "REFUNDED").contains(fee.getWalletStatus())) return true; if ("RELEASED".equals(fee.getWalletStatus()) && fee.getAmount().signum() == 0) return true; - if (!"SETTLED".equals(fee.getWalletStatus()) || fee.getSourceFeeId() == null) return false; - Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox WHERE aggregate_type='OVERSEAS_PROCUREMENT_FEE' AND aggregate_id=? AND status='PUBLISHED'", - Integer.class, String.valueOf(fee.getSourceFeeId())); - return count != null && count > 0; + return "SETTLED".equals(fee.getWalletStatus()) && fee.getSourceFeeId() != null + && fee.getWalletLedgerId() != null && fee.getSettledCommandEventId() != null; } /** Called under the procurement order lock before the final physical handoff or exception closure. */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java index 24297efb1..590501678 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java @@ -106,7 +106,7 @@ public class ProcurementLifecycleService { if(replay(order,"PLAN_WAREHOUSE",request.idempotencyKey(),request)) return outcome(order); version(order,request.version());open(order);ensureFlow(order); if(flow(order.getId()).get("actual_warehouse_id")!=null) throw conflict("首次实收已冻结实际仓库,请使用事实更正操作"); - var warehouse=warehouses.required(request.warehouseId()); + var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId())); order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));order.setPurchaseWarehouseName(warehouse.warehouseName()); complete(order,operator,"PLAN_WAREHOUSE",request.idempotencyKey(),request.reason(),request); return outcome(order); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java index 23e160e5d..33f1be50a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java @@ -109,7 +109,7 @@ public class ProcurementPhysicalService { LocalDateTime originAt; if(request.leg()==Leg.OVERSEAS) { if(request.warehouseId()==null || request.sourceCartonId()!=null) throw bad("海外实收须选择实际收货仓,不能指定国内来源箱"); - var warehouse=warehouses.required(request.warehouseId()); + var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId())); if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓"); jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?", warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java index ce575ab74..3b33d82b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java @@ -29,7 +29,7 @@ public class ProcurementQueryService { String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency, String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt, String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version, - LocalDateTime createdAt,LocalDateTime updatedAt) { } + LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { } public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { } public record Progress(String key,String title,LocalDateTime completedAt) { } public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { } @@ -60,14 +60,14 @@ public class ProcurementQueryService { var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo())); var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(), order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(), - (String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt()); + (String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection())); var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1", (rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId()); var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0); List progress=new ArrayList<>();progress.add(new Progress("CREATED","客户下单",order.getCreatedAt()));progress.add(new Progress("REVIEWED","采购审核",order.getReviewedAt())); for(var step:List.of(new String[]{"purchased_at","购买完成"},new String[]{"overseas_received_at","海外收货完成"},new String[]{"overseas_shipped_at","海外实际交寄"}, new String[]{"domestic_received_at","国内收货完成"},new String[]{"charges_confirmed_at","国内费用核定"},new String[]{"customer_shipped_at","已寄客户"},new String[]{"closed_at","异常关闭"})) { - progress.add(new Progress(step[0],step[1],date(flow.get(step[0])))); + progress.add(new Progress(step[0],ProcurementDirection.display(step[1],order.getProcurementDirection()),date(flow.get(step[0])))); } var manifest=physical.currentManifest(order.getId()); boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status")); @@ -124,7 +124,8 @@ public class ProcurementQueryService { action(result,permissions,"FINANCE_ADJUST","费用更正与退款","finance-adjust",null); action(result,permissions,"CANCEL","购买前取消","cancel",closed?stopped:purchased?"购买后不能取消,请处理实物资金后异常关闭":null); action(result,permissions,"EXCEPTION_CLOSE","异常关闭","manage",closed?stopped:!purchased?"购买前请取消订单":blockers!=null?blockers:reason(()->finance.requireShipmentReady(order.getId()))); - return List.copyOf(result); + return result.stream().map(a -> new Action(a.key(), ProcurementDirection.display(a.label(), order.getProcurementDirection()), + a.permission(), a.allowed(), ProcurementDirection.display(a.reason(), order.getProcurementDirection()))).toList(); } private static void action(List result,Set permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));} private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java index eb804e43c..815bb76e2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java @@ -52,7 +52,7 @@ public class ProcurementTransportService { if(request.leg()==Leg.OVERSEAS) { var id=(Number)lifecycle.flow(order.getId()).get("actual_warehouse_id"); if(id==null)throw conflict("尚无实际海外收货仓"); - sender=warehouses.sender(id.longValue()); + sender=ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.sender(id.longValue(), order.getProcurementDirection()) : warehouses.sender(id.longValue()); } else { var original=internationalAddresses(order); sender=original.receiver(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java index 36040d990..07f3e2656 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java @@ -18,6 +18,26 @@ public class ProcurementWarehouseDirectory { if(result==null || !result.successful() || result.data()==null) throw unavailable(); return result.data(); } + /** 国内采购消费国内仓目录,旧方向仍使用原海外收货仓目录。 */ + public List options(String direction) { + if (!ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))) return options(); + var result = client.domesticWarehouses(token); + if (result == null || !result.successful() || result.data() == null) throw unavailable(); + return result.data().stream().map(w -> new DomesticWarehouseDirectoryClient.ReceivingWarehouse( + w.id(), w.warehouseCode(), w.warehouseName(), "CN", "ACTIVE")).toList(); + } + /** 写入前核验公共作用域、启用状态和仓库类型,不信任前端候选。 */ + public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id, String direction) { + if (!ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction))) return required(id); + var result = client.activeWarehouse(id, token); + if (result == null || !result.successful() || result.data() == null || result.data().warehouse() == null) throw unavailable(); + var w = result.data().warehouse(); + if (!Long.valueOf(id).equals(w.id()) || !Long.valueOf(0).equals(w.tenantId()) + || !"DOMESTIC".equals(w.warehouseType()) || !"ACTIVE".equals(w.status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "国内采购须选择有效国内公共仓"); + } + return new DomesticWarehouseDirectoryClient.ReceivingWarehouse(w.id(), w.warehouseCode(), w.warehouseName(), w.countryCode(), w.status()); + } public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id) { var result=client.receivingWarehouse(id,token); if(result==null || !result.successful() || result.data()==null) throw unavailable(); @@ -28,19 +48,23 @@ public class ProcurementWarehouseDirectory { return warehouse; } public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) { - required(id); + return sender(id, ProcurementDirection.IMPORT); + } + /** 从起运仓主档生成发件信息,两种方向均校验作业地址。 */ + public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id, String direction) { + required(id, direction); var result=client.activeWarehouse(id,token); if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable(); var warehouse=result.data().warehouse(); if(!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId()) - || !"OVERSEAS".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable(); + || !(ProcurementDirection.EXPORT.equals(ProcurementDirection.normalize(direction)) ? "DOMESTIC" : "OVERSEAS").equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable(); var addresses=result.data().addresses(); var operating=addresses==null?List.of():addresses.stream() .filter(a->"OPERATING".equals(a.addressType())).toList(); if(operating.size()!=1 || warehouse.contactName()==null || warehouse.contactName().isBlank() || warehouse.contactPhone()==null || !warehouse.contactPhone().matches("[+0-9 ()-]{5,64}") || operating.get(0).addressLine1()==null || operating.get(0).addressLine1().isBlank()) { - throw new ResponseStatusException(HttpStatus.CONFLICT,"实际海外仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料"); + throw new ResponseStatusException(HttpStatus.CONFLICT,"实际起运仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料"); } var a=operating.get(0); String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode()) @@ -48,6 +72,6 @@ public class ProcurementWarehouseDirectory { return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address); } private static ResponseStatusException unavailable() { - return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"海外收货仓目录暂不可用,请稍后重试"); + return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"采购收货仓目录暂不可用,请稍后重试"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index d6b5614b6..c9faf205e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -490,7 +490,7 @@ public class RelabelApplicationService implements WalletResultHandler { String operationWarehouseId = request.operationWarehouseId().trim(); String relabelNo = compactNumbers.next("TB"); BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO); - boolean requiresWallet = chargeAmount.signum() > 0; + // 首版仅保留客户报价快照;应收须在海外费用登记并审核。 int holdIndex = 0; for (SourceInventory source : sources) { if (source.returnCarton() != null) continue; @@ -524,7 +524,7 @@ public class RelabelApplicationService implements WalletResultHandler { application.setNotifiedPackageCount(notified); application.setChargeAmount(chargeAmount); application.setChargeCurrency("CNY"); - application.setWalletStatus(requiresWallet ? "FREEZE_PENDING" : "NOT_REQUIRED"); + application.setWalletStatus("NOT_REQUIRED"); application.setIdempotencyKey(request.idempotencyKey().trim()); application.setRequestHash(requestHash); application.setCreatedBy(trim(operatorId)); @@ -550,12 +550,6 @@ public class RelabelApplicationService implements WalletResultHandler { } } - if (requiresWallet) { - String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", - relabelNo, chargeAmount, "换标订单费用冻结", 1L); - application.setWalletCommandEventId(walletEventId); - applications.updateById(application); - } for (SourceInventory source : sources) { insertItem(customerId, applicationId, source, now); @@ -876,20 +870,10 @@ public class RelabelApplicationService implements WalletResultHandler { private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId, List linkedOrders) { if (Set.of("NOT_REQUIRED", "SETTLE_PENDING", "SETTLED").contains(Objects.toString(application.getWalletStatus(), ""))) return; - BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); - String customerId = application.getCustomerId(), relabelNo = application.getRelabelNo(); - feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo, - new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(), - "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo, - application.getLastlegOrderId(), null, null, null, null, null, - parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE", - null, null, null, null, null, null, null, List.of(), LocalDate.now(), - application.getChargeCurrency(), application.getChargeAmount(), - quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null, - quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null, - null, null)); - walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, + if (!"FROZEN".equals(application.getWalletStatus())) throw conflict("旧换标费用尚未锁定,请先处理资金状态"); + String eventId = walletCommands.publish("SETTLE", Long.parseLong(application.getCustomerId()), "RELABEL_ORDER", application.getRelabelNo(), null, "换标完成,结算费用", 2L); + application.setWalletCommandEventId(eventId); application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); } @@ -1298,9 +1282,19 @@ public class RelabelApplicationService implements WalletResultHandler { @Override @Transactional public void handle(WalletCommandCompletedV1 result) { - RelabelApplication value = applications.findNo(String.valueOf(result.customerId()), result.businessNo()); + RelabelApplication value = applications.lockNo(String.valueOf(result.customerId()), result.businessNo()); if (value == null) throw notFound("Relabel application not found for wallet result"); + if ("NOT_REQUIRED".equals(value.getWalletStatus())) throw conflict("本换标单不使用旧资金流程"); + if ("SETTLE".equals(result.operation()) && "SETTLED".equals(value.getWalletStatus())) return; + if (value.getWalletCommandEventId() != null && !Objects.equals(value.getWalletCommandEventId(), result.commandEventId())) + throw conflict("资金回执不属于当前指令"); if ("SUCCEEDED".equals(result.result())) { + if ("SETTLE".equals(result.operation())) { + if (!"SETTLE_PENDING".equals(value.getWalletStatus()) || !"SETTLED".equals(result.holdStatus()) + || result.walletHoldId() == null || result.walletLedgerId() == null || result.amount() == null + || result.amount().compareTo(value.getChargeAmount()) != 0) throw conflict("换标扣款回执不完整"); + publishLegacySettled(value, result); + } value.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); value.setWalletErrorCode(null); value.setWalletErrorMessage(null); if ("FREEZE".equals(result.operation()) && Set.of("REJECTED", "CANCELLED").contains(value.getStatus())) { @@ -1319,6 +1313,21 @@ public class RelabelApplicationService implements WalletResultHandler { value.getWalletErrorMessage(), null); } + /** 旧冻结单仅在实扣成功后入账;已发布历史来源只补扣款引用,避免第二笔应收。 */ + private void publishLegacySettled(RelabelApplication app, WalletCommandCompletedV1 result) { + var linked = tailOrders(app); + var order = linked.get(0); + var quantity = BigDecimal.valueOf(Objects.requireNonNullElse(app.getTotalPackageCount(), 0)); + var today = LocalDate.now(); + var request = new OverseasFeeSourceDtos.SaveRequest(app.getCustomerId(), order.getCustomerName(), + "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", app.getId(), app.getRelabelNo(), app.getLastlegOrderId(), + null,null,null,null,null,parseLong(app.getOverseasWarehouseId()),"RELABEL_SERVICE","换标费", null,"RECEIVABLE", + null,null,null,null,null,null,null,List.of(),today,"CNY",app.getChargeAmount(), + quantity.signum()>0?quantity:null,quantity.signum()>0?"BOX":null,null,null,null); + feeSources.completeLegacyRelabelSettlement("WALLET_CALLBACK", "RELABEL:"+app.getRelabelNo(), request, + new com.baiyue.common.event.WalletSettlementReference(result.commandEventId(),result.walletHoldId(),result.walletLedgerId())); + } + private RelabelResponses.Item item(RelabelItem value) { return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(), value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java index b73f5a853..6b822960c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java @@ -26,12 +26,15 @@ public class ReturnSkuConversionService { @Resource private ReturnPackingTraceMapper traces; @Resource private ReturnInventoryOperationMapper operations; @Resource private ObjectMapper json; + @Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + /** 换标实作与原商品及计租来源在同一事务转换。 */ @Transactional public void apply(RelabelApplication app, List items, List facts, String round) { apply(app, items, facts, round, List.of()); } + /** 换标实作与原商品及计租来源在同一事务转换。 */ @Transactional public void apply(RelabelApplication app, List items, List facts, String round, List scans) { @@ -75,6 +78,7 @@ public class ReturnSkuConversionService { } } + /** 正向转换保存精确原托份额快照,重复作业不追加份额。 */ private void convert(RelabelApplication app, RelabelItem item, ReturnInventoryCarton carton, String oldSku, String newSku, long quantity, String round, List existing) { oldSku = code(oldSku); newSku = code(newSku); @@ -118,6 +122,8 @@ public class ReturnSkuConversionService { if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足"); try { row.setOwnershipJson(json.writeValueAsString(movedOwners)); } catch (java.io.IOException failure) { throw new IllegalStateException("Cannot save SKU conversion ownership", failure); } + // 库存与原计租份额同事务转换,保留精确来源以支持审核退回。 + row.setStorageRentSharesJson(storageRent.convertReturnSkuShares(carton.getCustomerId(),carton.getOverseasWarehouseId(),carton.getCartonMark(),oldSku,newSku,quantity)); conversions.insert(row); existing.add(row); traceConversion(row, movedOwners, false); } @@ -128,6 +134,7 @@ public class ReturnSkuConversionService { } } + /** 审核退回按原转换快照恢复库存及计租归属。 */ @Transactional public void reverse(RelabelApplication app) { var rows = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId())); Collections.reverse(rows); @@ -152,6 +159,8 @@ public class ReturnSkuConversionService { target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(0L); contents.insert(target); } else { target.setQuantity(Math.addExact(n(target.getQuantity()), quantity)); contents.updateById(target); } adjustBalances(carton, row.getNewSkuCode(), row.getOldSkuCode(), row.getProductNameZh(), quantity, removed, added); + // 必须恢复原份额,不能将目标 SKU 混合后的计租来源反向估算。 + storageRent.reverseReturnSkuShares(carton.getCustomerId(),carton.getOverseasWarehouseId(),carton.getCartonMark(),row.getOldSkuCode(),row.getNewSkuCode(),row.getStorageRentSharesJson()); traceConversion(row, ownership, true); row.setStatus("REVERSED"); row.setReversedAt(LocalDateTime.now()); conversions.updateById(row); refreshTypes(carton); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnSkuConversion.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnSkuConversion.java index f1101e234..2450f05a4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnSkuConversion.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnSkuConversion.java @@ -22,6 +22,8 @@ public class ReturnSkuConversion { private Long quantity; private String status; private String ownershipJson; + /** 原仓租批次迁移快照,审核退回时精确恢复。 */ + private String storageRentSharesJson; private LocalDateTime completedAt; private LocalDateTime confirmedAt; private LocalDateTime reversedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java index 78bfc42e0..5d9fff58b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java @@ -18,6 +18,8 @@ import java.util.*; /** Box quantities are released together after reconciliation to the immutable pallet receipt totals. */ @Service public class ReturnCartonCountService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + @Resource private ReturnReceiptMapper receipts; @Resource private ReturnOrderMapper orders; @Resource private ReturnReceiptService returnReceiptService; @@ -137,6 +139,8 @@ public class ReturnCartonCountService { } } if (!expected.equals(actual)) throw error(HttpStatus.CONFLICT, "各箱 SKU 数量汇总必须与本托签收数量一致;签收=" + expected + ",清点=" + actual); + // 只在原签收和全部待清点箱仍完整时恢复经明确授权的历史计租批次,失败与本次清点一并回滚。 + storageRent.ensureReturnCountLot(receipt.getId()); LocalDateTime now = LocalDateTime.now(); for (CountBox input : request.boxes()) { ReturnInventoryCarton box = byMark.get(input.cartonMark()); items.deleteCarton(box.getId()); @@ -156,6 +160,7 @@ public class ReturnCartonCountService { line.setBoxCount(newCounts.getOrDefault(line.getSkuCode(), 0L)); receiptItems.updateById(line); } + storageRent.rebuildReturnShares(receipt.getId()); returnReceiptService.refreshCountedTailCargo(receipt); if (sessions.release(identity.operatorKey(), receipt.getId()) != 1) throw error(HttpStatus.CONFLICT, "清点会话已变化,请重新核对"); Task result = task(receipt, boxes); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 5df975bca..2a650618d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -52,6 +52,8 @@ import java.util.HashMap; /** Return stock keeps its own receipt ownership, carton inventory and putaway facts. */ @Service public class ReturnInventoryService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + public static final String AVAILABLE = "AVAILABLE"; public static final String MERGED = "MERGED"; @@ -1176,6 +1178,9 @@ public class ReturnInventoryService { if (request.height() != null) target.setHeight(request.height()); target.setUpdatedAt(now); target.setLocationCode(null); target.setPutawayAt(null); cartons.updateById(target); cartons.clearPutaway(targetId); } + // 合箱保留每个原签收托的计租份额,不能按目标箱重新起租。 + for (TraceTransfer transfer : transfers) storageRent.moveReturnShares(customerId, target.getOverseasWarehouseId(), + transfer.source(), target.getCartonMark(), transfer.sku(), transfer.quantity()); for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(), transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now); for (ReturnInventoryCarton source : sources) if (completedRelabelSources.contains(source.getId()) && MERGED.equals(source.getStatus()) && source.getRelabelApplicationId() == null) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index d83e11ab4..536d32e6a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -57,6 +57,8 @@ import java.util.Set; /** Customer return forecasts and PDA receipt facts. A receipt batch is the storage-fee start fact. */ @Service public class ReturnReceiptService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + public static final String PENDING_RECEIPT = "PENDING_RECEIPT"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; @@ -709,6 +711,8 @@ public class ReturnReceiptService { batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo()); batch.setOverseasWarehouseId(order.getOverseasWarehouseId()); batch.setStorageBillingStatus("PENDING_PRICING"); batch.setStorageStartedAt(now); batch.setCreatedAt(now); batches.insert(batch); + // 签收即启动原托仓租,各分箱共享此原始时间与原托计费基数。 + storageRent.registerReturn(order, receipt); return confirmed(order, receipt, completed); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 8b5b024d9..9c807d0ac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -78,6 +78,7 @@ public class LastlegOrderService { @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private OverseasFeeSourceService feeSources; + @Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees; @Resource private LastlegOrderOperationLogMapper operationLogMapper; @Resource private LastlegOrderManualNodeMapper manualNodeMapper; @Resource private InboundPlanCartonMapper cartonMapper; @@ -400,6 +401,7 @@ public class LastlegOrderService { } } + /** 返回订单详情及统一费用审核、余额锁扣状态。 */ public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); returnOrderReferences.populateForRead(List.of(order)); @@ -412,7 +414,12 @@ public class LastlegOrderService { .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); - List fees = feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId); + List orderFees = new java.util.ArrayList<>( + feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId)); + // 换标费用以申请为主业务,履约详情同时保留旧订单费用和新申请费用。 + if ("RELABEL_APPLICATION".equals(order.getOrderType())) + orderFees.addAll(manualFees.relabelFeesForOrder(order.getCustomerId(), orderId)); + List fees = manualFees.describe(orderFees); List operations = operationLogMapper.selectList( new LambdaQueryWrapper() .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) @@ -438,22 +445,22 @@ public class LastlegOrderService { ? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null); } + /** 订单详情统一委托人工费用服务,头程待审核入账,海外业务先锁款。 */ @Transactional public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request, String operatorName, String idempotencyKey) { LastlegOrder order = findOwned(customerId, orderId); LocalDateTime now = LocalDateTime.now(); - OverseasFeeSource fee = feeSources.create(operatorName, idempotencyKey, + OverseasFeeSource fee = manualFees.create(operatorName, idempotencyKey, new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(), "OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId, null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()), order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(), - request.feeItemName(), request.direction(), request.supplierId(), request.supplierName(), + request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(), request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(), request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(), request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(), request.unitPrice(), request.periodStart(), request.periodEnd())); - fee = feeSources.submit(fee.getSourceFeeId(), operatorName, childKey(idempotencyKey, "submit")); LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/client/CustomerStoragePricingClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/client/CustomerStoragePricingClient.java new file mode 100644 index 000000000..74b54d9d4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/client/CustomerStoragePricingClient.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.storagerent.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.Pricing; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +/** 仓租价格权威仍为国内仓库服务,不在海外维护第二份客户报价。 */ +@FeignClient(name="warehouse-service",contextId="customerStoragePricingClient",url="${baiyue.clients.domestic-warehouse.url:}") +public interface CustomerStoragePricingClient { + /** 使用内部服务令牌读取指定真实库存客户的当前仓租价格。 */ + @GetMapping("/internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing") + ApiResponse pricing(@PathVariable("customerId") long customerId, + @RequestHeader("X-Internal-Service-Token") String token); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PdaStorageRentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PdaStorageRentController.java new file mode 100644 index 000000000..9a943796b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PdaStorageRentController.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.storagerent.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.service.*; +import com.baiyue.lastleg.storagerent.service.preparation.StorageRentPreparationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** PDA 仓租只允许操作本人绑定仓。 */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/pda/v1/last-leg/storage-rent") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaStorageRentController { + private final StorageRentService rents; + private final StorageRentPreparationService preparations; + /** 扫码前确认仓租与余额授信冻结结果。 */ + @PostMapping("/preparations") + public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody PrepareRequest request) { + return ApiResponse.success(preparations.prepare(PdaWarehouseIdentity.require(user),request)); + } + /** 查询已提交预占的真实财务回执状态。 */ + @GetMapping("/preparations/{id}") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) { + return ApiResponse.success(rents.preparation(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id)); + } + /** 放弃扫码释放尚未使用的仓租占用。 */ + @DeleteMapping("/preparations/{id}") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) { + return ApiResponse.success(rents.cancel(String.valueOf(PdaWarehouseIdentity.require(user).overseasWarehouseId()),id)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PlatformStorageRentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PlatformStorageRentController.java new file mode 100644 index 000000000..cb97262d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/controller/PlatformStorageRentController.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.storagerent.controller; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.service.StorageRentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +/** 海外仓租原批次及逐次出库费用查询。 */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/storage-rent") +@PreAuthorize("hasAuthority('platform:overseas-fee:list')") +public class PlatformStorageRentController { + private final StorageRentService service; + private final com.baiyue.lastleg.storagerent.service.StorageRentReviewService reviews; + /** 审核前修改仓租金额并强制保存原因,入口权限与其他费用编辑一致。 */ + @PostMapping("/fees/{sourceFeeId}/adjust") + @PreAuthorize("hasAuthority('platform:finance:fee-source:edit')") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long sourceFeeId,@jakarta.validation.Valid @RequestBody AdjustRentRequest request){ + platform(user);return ApiResponse.success(reviews.adjust(sourceFeeId,String.valueOf(user.userId()),request)); + } + /** 按客户、业务及库存状态分页查询。 */ + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute Query query) { + platform(user);return ApiResponse.success(service.list(query)); + } + /** 一个原批次下只列本批实际产生的费用。 */ + @GetMapping("/{id}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long id) { + platform(user);return ApiResponse.success(service.detail(id)); + } + /** 查询已有计租批次对应仓库。 */ + @GetMapping("/warehouse-options") + public ApiResponse> warehouses(@AuthenticationPrincipal AuthenticatedUser user) { + platform(user);return ApiResponse.success(service.warehouseOptions()); + } + /** 资金与客户信息只允许平台作用域查询。 */ + private static void platform(AuthenticatedUser user) { + if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可查询仓租"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java new file mode 100644 index 000000000..fea3e90c2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.storagerent.dto; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; + +/** 仓租列表、来源快照与 PDA 预校验协议。 */ +public final class StorageRentDtos { + /** 仅承载协议定义。 */ + private StorageRentDtos() { } + public record PrepareRequest(@NotBlank @Pattern(regexp="DISPATCH|OUTBOUND|OUTBOUND_PALLET|DISCARD") String mode, + @NotBlank @Size(max=128) String businessNo, Long itemId, Long positionId, @Positive Long packages, + String cartonMark, String palletNo, @NotBlank @Size(max=255) String idempotencyKey, + List cartonMarks, String skuCode, @PositiveOrZero Long productQuantity, + List lines, + List photoFileIds,List scannedCartonMarks,Boolean confirmAllCartons) { + /** 普通扫码不承载废弃作业的申请明细。 */ + public PrepareRequest(String mode,String businessNo,Long itemId,Long positionId,Long packages, + String cartonMark,String palletNo,String idempotencyKey,List cartonMarks,String skuCode,Long productQuantity) { + this(mode,businessNo,itemId,positionId,packages,cartonMark,palletNo,idempotencyKey,cartonMarks,skuCode,productQuantity,null,null,null,null); + } + } + public record PreparationResult(String preparationId, String status, boolean ready, BigDecimal amount, + String currency, BigDecimal originalAmount, String originalCurrency, BigDecimal exchangeRate, + String exchangeRateMonth, String message) { } + public record Query(String customerId, String businessNo, String warehouseId, String businessType, + String status, Long page, Long size) { } + public record WarehouseOption(String id, String warehouseName) { } + public record AdjustRentRequest(@NotNull @Min(1) Integer expectedVersion, + @NotNull @DecimalMin("0.00") @Digits(integer=12,fraction=2) BigDecimal amount, + @NotBlank @Size(max=500) String reason, @NotBlank @Size(max=128) String idempotencyKey) { } + public record Owner(String customerId, String customerName, String businessType, Long businessId, + String businessNo, Long orderId, String inboundCode, String warehouseId) { } + public record Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount, + LocalDateTime inboundAt, long days) { } + public record PricingTier(String id,Integer startDay,Integer endDay,BigDecimal unitPrice,Integer sortNo, + String feeType,String billingUnit) { } + public record Pricing(String configId,Long customerId,Integer version,String currency,List tiers,String source) { + /** 固化不可变阶梯快照,防止调用方修改已确认的计费依据。 */ + public Pricing { if(tiers!=null)tiers=List.copyOf(tiers); } + } + public record Plan(Owner owner, List allocations, LocalDateTime calculatedAt, BigDecimal amount,Pricing pricing) { + /** 本轮旧测试构造继续明确使用报价表默认值,实际预占必须读取配置服务。 */ + public Plan(Owner owner,List allocations,LocalDateTime calculatedAt,BigDecimal amount) { + this(owner,allocations,calculatedAt,amount,com.baiyue.lastleg.storagerent.service.StorageRentCalculator.defaults(owner==null?null:Long.valueOf(owner.customerId()))); + } + } + public record CurrencyAmount(String currency,BigDecimal amount) { } + public record LotView(Long id,String customerId,String customerName,String businessType,String businessNo, + String warehouseId,String warehouseName,String batchNo,OffsetDateTime inboundAt,long storageDays, + BigDecimal originalQuantity,BigDecimal remainingQuantity,String quantityUnit,BigDecimal billingQuantity, + String billingUnit,String ruleCode,String status,BigDecimal generatedAmount,String generatedCurrency,List generatedAmounts, + BigDecimal settledAmount,long chargeCount,long settledChargeCount) { } + public record ChargeView(Long id,String outboundNo,String businessNo,BigDecimal quantity,long storageDays, + BigDecimal originalAmount,String currency,BigDecimal settlementAmount,BigDecimal currencyRate,String status, + OffsetDateTime createdAt,OffsetDateTime outboundAt) { } + public record LotDetail(LotView lot,List charges) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java new file mode 100644 index 000000000..31489ffda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.storagerent.entity; + +import lombok.Data; + +/** 当前计费服务单只读投影,仓租不操作其他业务聚合。 */ +@Data +public class StorageRentBusinessSource { + private Long id; + private String businessNo; + private Long lastlegOrderId; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentLot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentLot.java new file mode 100644 index 000000000..d2ed3a8bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentLot.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.storagerent.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 实收原批次,库龄及计费基数不可被换标或移库重置。 */ +@Data +@TableName("ll_storage_rent_lot") +public class StorageRentLot { + /** 原批次主键。 */ + @TableId(type=IdType.INPUT) + private Long id; + /** 实收来源幂等键。 */ + private String sourceKey; + /** 客户标识。 */ + private String customerId; + /** 客户名称。 */ + private String customerName; + /** 海外仓标识。 */ + private String warehouseId; + /** 海外仓名称。 */ + private String warehouseName; + /** 原入库业务类型。 */ + private String businessType; + /** 原入库业务主键。 */ + private Long businessId; + /** 原入库业务单号。 */ + private String businessNo; + /** 尾程订单主键。 */ + private Long orderId; + /** 入仓码。 */ + private String inboundCode; + /** 普通库存余额主键。 */ + private Long balanceId; + /** 原实收批次。 */ + private String batchNo; + /** 商品编码。 */ + private String skuCode; + /** 仓租规则。 */ + private String ruleCode; + /** 实收原始数量。 */ + private BigDecimal originalQuantity; + /** 未出库原始数量。 */ + private BigDecimal remainingQuantity; + /** 原批次计费体积或托数。 */ + private BigDecimal originalUnits; + /** 实际入库时间。 */ + private LocalDateTime inboundAt; + /** 创建时间。 */ + private LocalDateTime createdAt; + /** 更新时间。 */ + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentPreparation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentPreparation.java new file mode 100644 index 000000000..934023156 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentPreparation.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.storagerent.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 一次扫码的资金冻结与不可变仓租快照。 */ +@Data +@TableName("ll_storage_rent_preparation") +public class StorageRentPreparation { + /** 预校验主键。 */ + @TableId(type=IdType.INPUT) + private Long id; + /** 作业海外仓。 */ + private String warehouseId; + /** 费用客户。 */ + private String customerId; + /** 扫码类型。 */ + private String mode; + /** 实际作业单号。 */ + private String executionNo; + /** 原扫码幂等键。 */ + private String requestKey; + /** 原扫码内容哈希。 */ + private String requestHash; + /** 冻结尝试次数。 */ + private Integer attempt; + /** 本次应收业务类型。 */ + private String businessType; + /** 本次应收业务主键。 */ + private Long businessId; + /** 本次应收业务单号。 */ + private String businessNo; + /** 资金与扫码状态。 */ + private String status; + /** 原批次计费不可变快照。 */ + private String planJson; + /** 美元仓租金额。 */ + private BigDecimal originalAmount; + /** 人民币结算金额。 */ + private BigDecimal amount; + /** 冻结确认汇率。 */ + private BigDecimal exchangeRate; + /** 汇率月份。 */ + private String exchangeRateMonth; + /** 冻结指令事件。 */ + private String freezeEventId; + /** 扣款指令事件。 */ + private String settleEventId; + /** 解冻指令事件。 */ + private String releaseEventId; + /** 解冻回执状态,失败后由恢复任务重发。 */ + private String releaseStatus; + /** 同一预占资金命令版本,重试使用新版本保留审计。 */ + private Long walletVersion; + /** 财务冻结主键。 */ + private String holdId; + /** 财务扣款流水。 */ + private String ledgerId; + /** 资金处理结果。 */ + @TableField(updateStrategy=FieldStrategy.ALWAYS) + private String errorMessage; + /** 财务费用来源主键。 */ + private Long sourceFeeId; + /** 预校验时间。 */ + private LocalDateTime createdAt; + /** 实际扫描出库时间。 */ + private LocalDateTime consumedAt; + /** 更新时间。 */ + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentShare.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentShare.java new file mode 100644 index 000000000..88ac40d96 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentShare.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.storagerent.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 退货箱内商品与原计租批次的数量归属。 */ +@Data +@TableName("ll_storage_rent_share") +public class StorageRentShare { + /** 普通库存当前余额,退货箱份额为空。 */ + private Long balanceId; + /** 普通库存当前批次,换标搬移保持原计租批次引用。 */ + private String batchNo; + /** 份额主键。 */ + @TableId(type=IdType.INPUT) + private Long id; + /** 原计租批次主键。 */ + private Long lotId; + /** 客户标识。 */ + private String customerId; + /** 海外仓标识。 */ + private String warehouseId; + /** 当前箱唛。 */ + private String cartonMark; + /** 商品编码。 */ + private String skuCode; + /** 剩余商品数量。 */ + private BigDecimal quantity; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGateway.java new file mode 100644 index 000000000..5505d74d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGateway.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.storagerent.gateway; + +import com.baiyue.lastleg.storagerent.client.CustomerStoragePricingClient; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** 无配置时只接受权威服务明确返回的默认报价,网络或非法配置不能降级免租。 */ +@Component +public class StorageRentPricingGateway { + @Resource private CustomerStoragePricingClient client; + @Value("${baiyue.security.internal-service-token}") private String token; + /** 不缓存客户报价;每次新预占读取当前版本,消费只用已保存的快照。 */ + public Pricing current(String customer) { + try { + long id=Long.parseLong(customer);var response=client.pricing(id,token); + if(response==null||!response.successful()||response.data()==null)throw invalid(); + var pricing=response.data();validate(pricing,id);return pricing; + } catch(ResponseStatusException failure){throw failure;} + catch(Exception failure){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"客户仓租配置暂不可用,不能出库",failure);} + } + /** 保存和重放都校验完整阶梯,避免缺档、错单位或混币产生低收费。 */ + public static void validate(Pricing pricing,long customer) { + if(pricing==null||!Objects.equals(pricing.customerId(),customer)||!("USD".equals(pricing.currency())||"CNY".equals(pricing.currency())) + ||!("CUSTOMER".equals(pricing.source())||"DEFAULT".equals(pricing.source()))||pricing.tiers()==null||pricing.tiers().isEmpty() + ||pricing.version()!=null&&pricing.version()<0||"CUSTOMER".equals(pricing.source())&&(pricing.version()==null||pricing.configId()==null||pricing.configId().isBlank()))throw invalid(); + for(var tier:pricing.tiers())if(tier==null||tier.startDay()==null||tier.startDay()<1||tier.unitPrice()==null||tier.unitPrice().signum()<0 + ||!("STORAGE".equals(tier.feeType())&&"CBM".equals(tier.billingUnit())||"RETURN_STORAGE".equals(tier.feeType())&&"PALLET".equals(tier.billingUnit())))throw invalid(); + for(String type:List.of("STORAGE","RETURN_STORAGE")) { + var tiers=pricing.tiers().stream().filter(t->type.equals(t.feeType())).sorted(Comparator.comparing(PricingTier::startDay)).toList(); + if(tiers.isEmpty())throw invalid();long next=1; + for(int i=0;i { + /** 当前读实收计租登记,补入库及出库后重放均不重复累计。 */ + @org.apache.ibatis.annotations.Select("SELECT lot_id FROM ll_storage_rent_inbound WHERE receipt_id=#{receiptId} FOR UPDATE") + Long inboundRentLot(@org.apache.ibatis.annotations.Param("receiptId") long receiptId); + /** 同一实收凭证仅登记一次,异常回滚同时撤销批次和份额增量。 */ + @org.apache.ibatis.annotations.Insert("INSERT INTO ll_storage_rent_inbound(receipt_id,lot_id,quantity,created_at) VALUES(#{receiptId},#{lotId},#{quantity},UTC_TIMESTAMP(3))") + int recordInboundRent(@org.apache.ibatis.annotations.Param("receiptId") long receiptId,@org.apache.ibatis.annotations.Param("lotId") long lotId,@org.apache.ibatis.annotations.Param("quantity") java.math.BigDecimal quantity); + /** 仓库名称是原业务引用快照,选项不构建独立仓库主档。 */ + @org.apache.ibatis.annotations.Select("SELECT warehouse_id AS id,MAX(warehouse_name) AS warehouse_name FROM ll_storage_rent_lot GROUP BY warehouse_id ORDER BY warehouse_name,warehouse_id") + java.util.List warehouseOptions(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentPreparationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentPreparationMapper.java new file mode 100644 index 000000000..bce0a7c53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentPreparationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.storagerent.mapper; + +import org.apache.ibatis.annotations.Mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.storagerent.entity.StorageRentPreparation; + +/** 一次扫码的资金冻结与不可变仓租快照持久化。 */ +@Mapper +public interface StorageRentPreparationMapper extends BaseMapper { + /** 关联原批次快照保留所有实际出库费用;直接展开避免关联子查询优化导致漏行。 */ + @org.apache.ibatis.annotations.Select("SELECT DISTINCT p.* FROM ll_storage_rent_preparation p JOIN JSON_TABLE(p.plan_json,'$.allocations[*]' COLUMNS(lot_id BIGINT PATH '$.lotId')) a ON a.lot_id=#{lotId} WHERE p.consumed_at IS NOT NULL ORDER BY p.consumed_at DESC,p.id DESC") + java.util.List charges(@org.apache.ibatis.annotations.Param("lotId") long lotId); + /** 分页批量展开快照,多个份额命中同笔费用时去重,防止重复累计扣款。 */ + @org.apache.ibatis.annotations.Select("") + java.util.List chargesForLots(@org.apache.ibatis.annotations.Param("lotIds") java.util.List lotIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentShareMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentShareMapper.java new file mode 100644 index 000000000..b96c16a40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentShareMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.storagerent.mapper; + +import org.apache.ibatis.annotations.Mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.storagerent.entity.StorageRentShare; + +/** 退货箱内商品与原计租批次的数量归属持久化。 */ +@Mapper +public interface StorageRentShareMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java new file mode 100644 index 000000000..1ec305169 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java @@ -0,0 +1,170 @@ +package com.baiyue.lastleg.storagerent.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.mapper.*; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.inventory.mapper.*; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.*; + +/** 仓租全部持久化及同服务来源解析,服务不直接访问 Mapper。 */ +@Repository +public class StorageRentRepository { + @Resource private StorageRentLotMapper lots; + @Resource private StorageRentShareMapper shares; + @Resource private StorageRentPreparationMapper preparations; + @Resource private LastlegOrderMapper orders; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private DispatchAggregateMapper dispatches; + @Resource private DispatchItemMapper dispatchItems; + @Resource private OutboundPdaTaskMapper tasks; + @Resource private OutboundOrderMapper outbounds; + @Resource private OutboundPalletItemMapper outboundItems; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private ReturnInventoryCartonItemMapper cartonItems; + @Resource private ReturnOrderMapper returns; + @Resource private ReturnReceiptMapper returnReceipts; + @Resource private ReturnReceiptItemMapper returnReceiptItems; + @Resource private ReturnReceiptBatchMapper returnBatches; + @Resource private StorageRentBusinessSourceMapper businessSources; + + /** 按唯一实收来源读取,防止重复入库增加仓租基数。 */ + public StorageRentLot source(String key) { return lots.selectOne(new LambdaQueryWrapper().eq(StorageRentLot::getSourceKey,key)); } + /** 当前读锁定合并批次,避免事务快照漏掉其他入库事务刚建的批次。 */ + public StorageRentLot lockSource(String key) {return lots.selectOne(new LambdaQueryWrapper().eq(StorageRentLot::getSourceKey,key).last("FOR UPDATE"));} + /** 头程同单补入库串行化,不使用库存剩余量判定凭证是否已处理。 */ + public LastlegOrder lockOrder(long id) {return orders.selectOne(new LambdaQueryWrapper().eq(LastlegOrder::getId,id).last("FOR UPDATE"));} + /** 读取已登记的实收凭证,出库清空份额后仍保持幂等。 */ + public Long inboundRentLot(long receiptId) {return lots.inboundRentLot(receiptId);} + /** 登记本次实收对原计租批次的增量,同入库事务提交。 */ + public void recordInboundRent(long receiptId,long lotId,java.math.BigDecimal quantity) {lots.recordInboundRent(receiptId,lotId,quantity);} + /** 补入库查找含零数量的原库存份额,避免违反同批次唯一约束。 */ + public StorageRentShare lockStockShare(long lotId,long balanceId,String batchNo) {return shares.selectOne(new LambdaQueryWrapper() + .eq(StorageRentShare::getLotId,lotId).eq(StorageRentShare::getBalanceId,balanceId).eq(StorageRentShare::getBatchNo,batchNo).last("FOR UPDATE"));} + /** 保存新的原始计租批次。 */ + public void insert(StorageRentLot lot) { lots.insert(lot); } + /** 锁定原批次,出库扣减与实际库存处于同一事务。 */ + public StorageRentLot lockLot(long id) { return lots.selectOne(new LambdaQueryWrapper().eq(StorageRentLot::getId,id).last("FOR UPDATE")); } + /** 普通库存只解析同一原余额和原批次,不能跨入仓码猜来源。 */ + public List stockLots(long balanceId,String batchNo) { return lots.selectList(new LambdaQueryWrapper().eq(StorageRentLot::getBalanceId,balanceId).eq(StorageRentLot::getBatchNo,batchNo).gt(StorageRentLot::getRemainingQuantity,0).orderByAsc(StorageRentLot::getId).last("FOR UPDATE")); } + /** 当前库存的原实收份额跨 SKU 和批次搬移后仍引用原计租批次。 */ + public List stockShares(String customer,String warehouse,long balanceId,String batchNo) { + return shares.selectList(new LambdaQueryWrapper().eq(StorageRentShare::getCustomerId,customer) + .eq(StorageRentShare::getWarehouseId,warehouse).eq(StorageRentShare::getBalanceId,balanceId).eq(StorageRentShare::getBatchNo,batchNo) + .gt(StorageRentShare::getQuantity,0).orderByAsc(StorageRentShare::getLotId,StorageRentShare::getId).last("FOR UPDATE")); + } + /** 更新已实际出库后的原批次剩余数量。 */ + public void save(StorageRentLot lot) { lots.updateById(lot); } + /** 保存原托商品归属,合箱不复制计租基数。 */ + public void insert(StorageRentShare share) { shares.insert(share); } + /** 锁定某箱某商品的原批次归属,顺序固定避免并发合箱与出库互相覆盖。 */ + public List shares(String customer,String warehouse,String mark,String sku) { + return shares.selectList(new LambdaQueryWrapper().eq(StorageRentShare::getCustomerId,customer) + .eq(StorageRentShare::getWarehouseId,warehouse).eq(StorageRentShare::getCartonMark,mark) + .eq(sku!=null,StorageRentShare::getSkuCode,sku).gt(StorageRentShare::getQuantity,0) + .orderByAsc(StorageRentShare::getLotId,StorageRentShare::getId).last("FOR UPDATE")); + } + /** 按主键锁定冻结时记录的商品归属。 */ + public StorageRentShare lockShare(long id) { return shares.selectOne(new LambdaQueryWrapper().eq(StorageRentShare::getId,id).last("FOR UPDATE")); } + /** 更新或清空原托在当前箱的剩余份额。 */ + public void save(StorageRentShare share) { shares.updateById(share); } + /** 签收后首次清点可重建该批次分箱,业务层确保尚未出库。 */ + public void deleteShares(long lotId) { shares.delete(new LambdaQueryWrapper().eq(StorageRentShare::getLotId,lotId)); } + /** 锁定同一次扫码的最新资金尝试,保留旧尝试供解冻回执关联。 */ + public StorageRentPreparation latest(String warehouse,String mode,String key) { + return preparations.selectOne(new LambdaQueryWrapper().eq(StorageRentPreparation::getWarehouseId,warehouse) + .eq(StorageRentPreparation::getMode,mode).eq(StorageRentPreparation::getRequestKey,key) + .orderByDesc(StorageRentPreparation::getAttempt).last("LIMIT 1 FOR UPDATE")); + } + /** 回执及取消按主键锁定原冻结记录。 */ + public StorageRentPreparation lockPreparation(long id) { return preparations.selectOne(new LambdaQueryWrapper().eq(StorageRentPreparation::getId,id).last("FOR UPDATE")); } + /** 费用列表批量读取本次出库的精确计费依据。 */ + public List forFees(Collection ids) { return ids.isEmpty()?List.of():preparations.selectList(new LambdaQueryWrapper().in(StorageRentPreparation::getSourceFeeId,ids)); } + /** 创建资金尝试,唯一键保证扫码重试不会重复冻结。 */ + public void insert(StorageRentPreparation value) { preparations.insert(value); } + /** 保存冻结、扣款、解冻状态及原事件身份。 */ + public void save(StorageRentPreparation value) { preparations.updateById(value); } + /** 取消作业释放未使用的冻结。 */ + public List pending(String mode,String execution) { return preparations.selectList(new LambdaQueryWrapper().eq(StorageRentPreparation::getMode,mode).eq(StorageRentPreparation::getExecutionNo,execution).in(StorageRentPreparation::getStatus,"FREEZE_PENDING","READY","FAILED").orderByAsc(StorageRentPreparation::getId).last("FOR UPDATE")); } + /** 列表只查询指定客户、仓库与业务类型,避免全量内存分页。 */ + public Page page(Query q,long page,long size) { + var filter=new LambdaQueryWrapper().eq(text(q.customerId()),StorageRentLot::getCustomerId,q.customerId()) + .like(text(q.businessNo()),StorageRentLot::getBusinessNo,q.businessNo()).eq(text(q.warehouseId()),StorageRentLot::getWarehouseId,q.warehouseId()) + .eq(text(q.businessType()),StorageRentLot::getBusinessType,q.businessType()); + if("IN_STOCK".equals(q.status()))filter.gt(StorageRentLot::getRemainingQuantity,0); + if("CLEARED".equals(q.status())||"OUTBOUNDED".equals(q.status()))filter.eq(StorageRentLot::getRemainingQuantity,0); + return lots.selectPage(new Page<>(page,size),filter.orderByDesc(StorageRentLot::getInboundAt,StorageRentLot::getId)); + } + /** 详情读取原始批次。 */ + public StorageRentLot lot(long id) { return lots.selectById(id); } + /** 批量读取费用快照引用的原始计租来源,避免逐条明细查询。 */ + public List lots(Collection ids) {return ids.isEmpty()?List.of():lots.selectBatchIds(ids);} + /** 根据快照中原批次编号查出库费用,JSON_SEARCH 不依赖模糊数字匹配。 */ + public List charges(long lotId) { return preparations.charges(lotId); } + /** 当前页一次读取所有已出库快照,避免每行执行 JSON_TABLE。 */ + public List charges(List lotIds) {return lotIds.isEmpty()?List.of():preparations.chargesForLots(lotIds);} + /** 限量查出过期或失败记录,业务恢复逐条在独立事务锁定复核。 */ + public List recoveryCandidates(java.time.LocalDateTime expiry,java.time.LocalDateTime retryBefore) { + return preparations.selectList(new LambdaQueryWrapper() + .and(w->w.and(x->x.in(StorageRentPreparation::getStatus,"FREEZE_PENDING","READY").lt(StorageRentPreparation::getCreatedAt,expiry)) + .or(x->x.and(y->y.in(StorageRentPreparation::getStatus,"SETTLE_FAILED","REVIEW_FREEZE_FAILED").or().eq(StorageRentPreparation::getReleaseStatus,"FAILED")).lt(StorageRentPreparation::getUpdatedAt,retryBefore))) + .orderByAsc(StorageRentPreparation::getUpdatedAt).last("LIMIT 100")).stream().map(StorageRentPreparation::getId).toList(); + } + /** 仓库选项来自当前计租业务引用,不建立第二套仓库主档。 */ + public List warehouseOptions() { return lots.warehouseOptions(); } + /** 业务来源订单。 */ + public LastlegOrder order(Long id) { return id==null?null:orders.selectById(id); } + /** 原扫码选择的真实库存位置。 */ + public InventoryPosition position(Long id) { return id==null?null:positions.selectById(id); } + /** 原库存余额所属订单。 */ + public InventoryBalance balance(Long id) { return id==null?null:balances.selectById(id); } + /** 排车仓库及状态锁。 */ + public DispatchAggregate dispatch(String number) { return dispatches.lock(number); } + /** 排车单明细,用于解析真实费用客户和业务单。 */ + public DispatchItem dispatchItem(Long id) { return id==null?null:dispatchItems.selectById(id); } + /** PDA 出库工单与绑定仓校验。 */ + public OutboundPdaTask task(String warehouse,String number) { return tasks.lockByNoAndWarehouse(number,warehouse); } + /** 实际出库单。 */ + public OutboundOrder outbound(Long id) { return outbounds.selectById(id); } + /** 出库明细的原商品与原申请快照。 */ + public OutboundPalletItem outboundItem(Long id) { return id==null?null:outboundItems.selectById(id); } + /** 原退货箱,商品数量和份额单独锁定。 */ + public ReturnInventoryCarton carton(String mark) { return cartons.lockMark(mark); } + /** 原签收分箱列表。 */ + public List receiptCartons(long id) { return cartons.lockReceipt(id); } + /** 箱内每商品的当前实物数量。 */ + public List items(long id) { return cartonItems.lockCarton(id); } + /** 历史清点修复也锁定原始签收凭证,不根据箱码推断签收时间或原托数。 */ + public ReturnReceipt lockReturnReceipt(long id) {return returnReceipts.selectOne(new LambdaQueryWrapper().eq(ReturnReceipt::getId,id).last("FOR UPDATE"));} + /** 原退货业务归属必须与签收、库存箱和计租批次完全相同。 */ + public ReturnOrder returnOrder(long id) {return returns.selectById(id);} + /** 原签收商品明细是件数权威,不能从清点输入补造原数量。 */ + public List returnReceiptItems(long id) {return returnReceiptItems.selectList(new LambdaQueryWrapper().eq(ReturnReceiptItem::getReceiptId,id).last("FOR UPDATE"));} + /** 已保存的签收计费起点用于交叉核实历史时间和仓库。 */ + public List returnReceiptBatches(long id) {return returnBatches.selectList(new LambdaQueryWrapper().eq(ReturnReceiptBatch::getReceiptId,id).last("FOR UPDATE"));} + /** 本次退货应收归属。 */ + public ReturnOrder returnOrder(String customer,String no) { return returns.selectOne(new LambdaQueryWrapper().eq(ReturnOrder::getCustomerId,customer).eq(ReturnOrder::getReturnNo,no)); } + /** 从换标出库单解析当前业务申请。 */ + public StorageRentBusinessSource relabel(String customer,String outbound) { return businessSources.relabel(customer,outbound); } + /** 排车明细使用明确换标业务单号匹配当前独立服务申请。 */ + public StorageRentBusinessSource relabelNo(String customer,String no) { return businessSources.relabelNo(customer,no); } + /** 按明确的转运单号解析归属。 */ + public StorageRentBusinessSource transfer(String customer,String no) { return businessSources.transfer(customer,no); } + /** 原订单映射其独立仓储转运工单。 */ + public StorageRentBusinessSource transferByTail(String customer,Long id) { return businessSources.transferByTail(customer,id); } + /** 不把空字符串用于查询过滤。 */ + private static boolean text(String value) { return value!=null&&!value.isBlank(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/scheduler/StorageRentRecoveryScheduler.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/scheduler/StorageRentRecoveryScheduler.java new file mode 100644 index 000000000..d64cbfff7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/scheduler/StorageRentRecoveryScheduler.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.storagerent.scheduler; + +import com.baiyue.lastleg.storagerent.service.StorageRentService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** 未使用预占自动释放,资金失败保留原账龄并恢复处理。 */ +@Slf4j +@Component +public class StorageRentRecoveryScheduler { + @Resource private StorageRentService service; + /** 单条失败不阻断其余客户恢复,事务锁保证多实例不会重复发布。 */ + @Scheduled(fixedDelayString="${lastleg.storage-rent.recovery-delay-ms:60000}") + public void recover() { + for(Long id:service.recoveryCandidates())try{service.recover(id);}catch(Exception failure){log.warn("仓租资金恢复失败 preparationId={}",id,failure);} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculator.java new file mode 100644 index 000000000..a5ef6d863 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculator.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.storagerent.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; + +/** 仓租按实收时刻起每满或不足二十四小时进一天,分段累加。 */ +public final class StorageRentCalculator { + /** 纯计费工具不允许实例化。 */ + private StorageRentCalculator() { } + /** 零时长不收费,任何正时长向上取整,禁止以自然日期代替库龄。 */ + public static long days(LocalDateTime inbound, LocalDateTime outbound) { + if (inbound == null || outbound == null || outbound.isBefore(inbound)) throw new IllegalArgumentException("计租时间无效"); + Duration elapsed = Duration.between(inbound, outbound); + long seconds = elapsed.getSeconds(); + return seconds / 86400 + (seconds % 86400 > 0 || elapsed.getNano() > 0 ? 1 : 0); + } + /** 返回每单位的累计租金,区间端点与报价表保持一致。 */ + public static BigDecimal unitAmount(String rule, long days) { + return unitAmount(defaults(null),rule,days); + } + /** 已确认的客户阶梯按业务类型分组累加,不能混用退货和中转单位。 */ + public static BigDecimal unitAmount(Pricing pricing,String rule,long days) { + if(days<0)throw new IllegalArgumentException("库龄不能为负数"); + String type=switch(rule){case "TRANSIT"->"STORAGE";case "RETURN"->"RETURN_STORAGE";default->throw new IllegalArgumentException("仓租规则无效");}; + var tiers=pricing.tiers().stream().filter(t->type.equals(t.feeType())).toList(); + if(tiers.isEmpty())throw new IllegalArgumentException("仓租报价缺少业务阶梯"); + return tiers.stream().map(t->BigDecimal.valueOf(Math.max(0,Math.min(days,t.endDay()==null?Long.MAX_VALUE:t.endDay())-t.startDay()+1)).multiply(t.unitPrice())).reduce(BigDecimal.ZERO,BigDecimal::add); + } + /** 每次出库只承担原批次对应份额,退货原托的拆分不重新向上取托数。 */ + public static BigDecimal amount(String rule, LocalDateTime inbound, LocalDateTime outbound, + BigDecimal originalUnits, BigDecimal originalQuantity, BigDecimal shippedQuantity) { + return amount(defaults(null),rule,inbound,outbound,originalUnits,originalQuantity,shippedQuantity); + } + /** 实际出库使用预占所保存的价格快照,不读取客户当前报价。 */ + public static BigDecimal amount(Pricing pricing,String rule,LocalDateTime inbound,LocalDateTime outbound, + BigDecimal originalUnits,BigDecimal originalQuantity,BigDecimal shippedQuantity) { + if (originalUnits == null || originalUnits.signum() <= 0 || originalQuantity == null || originalQuantity.signum() <= 0 + || shippedQuantity == null || shippedQuantity.signum() <= 0 || shippedQuantity.compareTo(originalQuantity) > 0) + throw new IllegalArgumentException("计租体积或原批次数量不完整"); + return unitAmount(pricing,rule, days(inbound, outbound)).multiply(originalUnits).multiply(shippedQuantity) + .divide(originalQuantity, 12, RoundingMode.HALF_UP); + } + /** 两张报价表仅作为明确默认报价及本轮测试构造,服务故障不调用此默认值。 */ + public static Pricing defaults(Long customer) { + return new Pricing(null,customer,null,"USD",List.of( + tier("STORAGE","CBM",1,7,"0"),tier("STORAGE","CBM",8,30,"0.8"),tier("STORAGE","CBM",31,60,"1"),tier("STORAGE","CBM",61,90,"1.5"),tier("STORAGE","CBM",91,null,"2"), + tier("RETURN_STORAGE","PALLET",1,30,"1.5"),tier("RETURN_STORAGE","PALLET",31,60,"2"),tier("RETURN_STORAGE","PALLET",61,90,"3"),tier("RETURN_STORAGE","PALLET",91,null,"4")),"DEFAULT"); + } + /** 默认阶梯也保留业务和单位,使快照协议完全一致。 */ + private static PricingTier tier(String type,String unit,int start,Integer end,String rate){return new PricingTier(null,start,end,new BigDecimal(rate),start,type,unit);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java new file mode 100644 index 000000000..de1fe8c9c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java @@ -0,0 +1,213 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.common.event.WalletSettlementReference; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.*; +import com.baiyue.lastleg.financefee.entity.*; +import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository; +import com.baiyue.lastleg.financefee.service.*; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.entity.StorageRentPreparation; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.*; +import java.time.*; +import java.util.*; +import java.security.MessageDigest; + +/** 出库仓租沿原冻结审核和调整;资金成功回执才确认实扣,不生成第二笔应收。 */ +@Service +@RequiredArgsConstructor +public class StorageRentReviewService implements StorageRentFeeWorkflow { + private final StorageRentRepository rents; + private final OverseasManualFeeRepository fees; + private final OverseasFeeSourceService sources; + private final OverseasFeeCurrencyConverter converter; + private final WalletCommandOutboxService wallet; + private final ObjectMapper json; + + /** 来源标记只用于路由,实际写入还要核对仓租和费用持久化关联。 */ + public static boolean isRent(OverseasFeeSource fee) { + return "STORAGE_RENT".equals(fee.getCreatedBy()) && "FEE_TYPE_077".equals(fee.getFeeItemCode()); + } + /** 旧版已扣仓租没有审核资金编排,仍由原回执状态机收尾。 */ + public boolean managed(StorageRentPreparation rent){return rent.getSourceFeeId()!=null&&fees.funding(rent.getSourceFeeId())!=null;} + + /** 出库事务内登记应收和原资金占用,尚未审核时不发结算指令。 */ + public void create(StorageRentPreparation rent, SaveRequest request, Plan plan) { + var conversion = new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),rent.getOriginalAmount(), + rent.getExchangeRate(),rent.getExchangeRateMonth(),rent.getAmount()); + var fee = sources.createPendingStorageRent(rent.getId().toString(),request,conversion); + rent.setSourceFeeId(fee.getSourceFeeId()); rent.setStatus("PENDING_REVIEW"); + var funding = new OverseasFeeFunding(); funding.setSourceFeeId(fee.getSourceFeeId()); + funding.setCustomerId(rent.getCustomerId()); funding.setCollectionMode("WALLET"); + funding.setWalletStatus("FROZEN"); funding.setWalletBusinessNo(rent.getId().toString()); + funding.setWalletHoldId(rent.getHoldId()); funding.setCommandVersion(rent.getWalletVersion()); + funding.setCommandHistory(new LinkedHashMap<>()); funding.setCreatedAt(now()); funding.setUpdatedAt(now()); + funding.getCommandHistory().put("holdAmount",rent.getAmount().toPlainString()); + if(rent.getFreezeEventId()!=null)funding.getCommandHistory().put("initialFreeze",rent.getFreezeEventId()); + fees.insertFunding(funding); + } + + /** 列表及订单明细共用精确入库、出库及实扣依据,零元不伪造扣款流水。 */ + public void describe(List rows) { + var ids=rows.stream().filter(StorageRentReviewService::isRent).map(OverseasFeeSource::getSourceFeeId).toList(); + var preparations=new HashMap(); + for(var rent:rents.forFees(ids))preparations.put(rent.getSourceFeeId(),rent); + var plans=new HashMap(); + for(var rent:preparations.values())plans.put(rent.getId(),plan(rent)); + var lotIds=plans.values().stream().flatMap(p->p.allocations().stream()).map(Allocation::lotId).distinct().toList(); + var lots=new HashMap(); + for(var lot:rents.lots(lotIds))lots.put(lot.getId(),lot); + for(var fee:rows){var rent=preparations.get(fee.getSourceFeeId());if(rent==null)continue; + var plan=plans.get(rent.getId()); + fee.setStorageRent(new OverseasFeeSource.RentDetail(rent.getExecutionNo(),rent.getConsumedAt().atOffset(ZoneOffset.UTC), + plan.amount(),"SETTLED".equals(rent.getStatus())?fee.getAmount():null, + plan.allocations().stream().map(a->{ + var lot=lots.get(a.lotId()); + // 只展示同客户、同仓库的原批次来源,缺失关联不猜测为当前换标单。 + boolean owned=lot!=null&&Objects.equals(lot.getCustomerId(),fee.getCustomerId()) + &&Objects.equals(lot.getWarehouseId(),rent.getWarehouseId()); + return new OverseasFeeSource.RentLine(a.lotId(),a.inboundAt().atOffset(ZoneOffset.UTC),a.quantity(),a.days(),a.amount(), + owned?lot.getBusinessType():null,owned?lot.getBusinessNo():null,owned?lot.getBatchNo():null); + }).toList())); + } + } + + /** 审核与幂等命令同事务保存;审批通过后才申请按确认金额结算。 */ + @Transactional + public OverseasFeeSource review(long id,String operator,String name,ReviewRequest request){ + var rent=requiredRent(id);var fee=requiredFee(rent);var funding=fees.lockFunding(id); + if(replayed(operator,"RENT_REVIEW",request.idempotencyKey(),id,request))return fee; + if(!Objects.equals(fee.getSourceVersion(),request.expectedVersion()))throw conflict("仓租金额版本已变化,请刷新后重新审核"); + if(!"PENDING".equals(fee.getReviewStatus())||!"PENDING_REVIEW".equals(rent.getStatus()) + ||funding==null||!"FROZEN".equals(funding.getWalletStatus()))throw conflict("仓租尚未锁定或已审核,请刷新"); + if(!Set.of("APPROVE","REJECT").contains(request.decision()))throw conflict("审核操作无效"); + boolean approved="APPROVE".equals(request.decision()); + if(!approved&&(request.reason()==null||request.reason().isBlank()))throw conflict("请填写驳回原因"); + fee.setReviewStatus(approved?"APPROVED":"REJECTED");fee.setReviewerId(operator);fee.setReviewerName(name); + fee.setReviewedAt(now());fee.setReviewReason(request.reason());fee.setUpdatedBy(operator);fee.setUpdatedAt(now()); + if(!approved){rent.setStatus("REJECTED");if(rent.getHoldId()!=null)send(rent,funding,"RELEASE");else funding.setWalletStatus("RELEASED");} + else if(fee.getAmount().signum()==0){ + if(rent.getHoldId()!=null){rent.setStatus("CONSUMED");send(rent,funding,"RELEASE");} + else{rent.setStatus("SETTLED");funding.setWalletStatus("SETTLED");fee.setPublicationStatus("PUBLISHED");} + }else{rent.setStatus("CONSUMED");send(rent,funding,"SETTLE_ACTUAL");} + save(rent,fee,funding);record(operator,"RENT_REVIEW",request.idempotencyKey(),id,request,fee.getSourceVersion());return fee; + } + + /** 审核前改费生成不可覆盖的新版本,固定原币和原汇率,并强制保存说明。 */ + @Transactional + public OverseasFeeSource adjust(long id,String operator,AdjustRentRequest request){ + var rent=requiredRent(id);var old=requiredFee(rent);var funding=fees.lockFunding(id); + if(replayed(operator,"RENT_ADJUST",request.idempotencyKey(),id,request))return old; + if(funding==null||!"FROZEN".equals(funding.getWalletStatus())||!"PENDING_REVIEW".equals(rent.getStatus())||!"PENDING".equals(old.getReviewStatus()) + ||!Objects.equals(old.getSourceVersion(),request.expectedVersion()))throw conflict("仅当前待审核仓租可以调整,请刷新"); + if(request.reason()==null||request.reason().isBlank()||request.reason().trim().length()>500 + ||request.amount()==null||request.amount().signum()<0||request.amount().scale()>2)throw conflict("请填写有效金额和调整说明"); + var next=json.convertValue(old,OverseasFeeSource.class);next.setId(IdWorker.getId());next.setSourceVersion(old.getSourceVersion()+1); + next.setOriginalAmount(request.amount());next.setFeeRemark(request.reason().trim());next.setUpdatedBy(operator);next.setUpdatedAt(now());next.setStorageRent(null); + if(next.getExchangeRate()==null&&request.amount().signum()>0){ + var converted=converter.convert(next.getOriginalCurrency(),request.amount(),next.getBusinessDate()); + next.setExchangeRate(converted.exchangeRate());next.setExchangeRateMonth(converted.exchangeRateMonth()); + rent.setExchangeRate(converted.exchangeRate());rent.setExchangeRateMonth(converted.exchangeRateMonth()); + } + next.setAmount(request.amount().signum()==0?BigDecimal.ZERO.setScale(2):request.amount().multiply(next.getExchangeRate()).setScale(2,RoundingMode.HALF_UP)); + rent.setOriginalAmount(next.getOriginalAmount());rent.setAmount(next.getAmount()); + // 原非零冻结不提前释放;Finance 按实额结算时原子校验差额,余额不足保留原冻结。 + if(rent.getHoldId()==null&&next.getAmount().signum()>0){rent.setStatus("REVIEW_FREEZE_PENDING");send(rent,funding,"FREEZE");} + old.setSourceStatus("SUPERSEDED");fees.updateFee(old);fees.insertFee(next); + rent.setUpdatedAt(now());rents.save(rent);funding.setUpdatedAt(now());fees.updateFunding(funding); + record(operator,"RENT_ADJUST",request.idempotencyKey(),id,request,next.getSourceVersion());return next; + } + + /** 原冻结身份接收资金结果,只有批准且匹配原指令的回执可登记已扣金额。 */ + public void handle(StorageRentPreparation rent,WalletCommandCompletedV1 event){ + var fee=requiredFee(rent);var funding=fees.lockFunding(fee.getSourceFeeId()); + if("FREEZE".equals(event.operation())&&Objects.equals(funding.getCommandHistory().get("initialFreeze"),event.commandEventId())){ + if(!Objects.equals(event.businessNo(),rent.getId().toString())||!"STORAGE_RENT".equals(event.businessType()) + ||!fee.getCustomerId().equals(String.valueOf(event.customerId()))||!"SUCCEEDED".equals(event.result()) + ||!"FROZEN".equals(event.holdStatus())||!Objects.equals(rent.getHoldId(),event.walletHoldId()) + ||event.amount()==null||event.amount().compareTo(new BigDecimal(funding.getCommandHistory().get("holdAmount")))!=0) + throw conflict("原仓租冻结回执不一致"); + return; + } + String expected=funding.getCommandHistory().get(event.commandEventId());String fingerprint=event.operation()+":"+hash(event); + if(fingerprint.equals(expected))return; + if(!Objects.equals(funding.getCommandEventId(),event.commandEventId())||!Objects.equals(expected,event.operation()) + ||!Objects.equals(event.businessNo(),funding.getWalletBusinessNo())||!"STORAGE_RENT".equals(event.businessType()) + ||!fee.getCustomerId().equals(String.valueOf(event.customerId()))||event.completedAt()==null + ||!Set.of("SUCCEEDED","FAILED").contains(Objects.toString(event.result(),"")))throw conflict("仓租资金回执与原指令不一致"); + boolean success="SUCCEEDED".equals(event.result()); + var expectedAmount="RELEASE".equals(event.operation())?new BigDecimal(funding.getCommandHistory().get("holdAmount")):rent.getAmount(); + if(success&&(event.amount()==null||event.amount().compareTo(expectedAmount)!=0))throw conflict("仓租资金金额不一致"); + if(success&&!"FREEZE".equals(event.operation())&&!Objects.equals(rent.getHoldId(),event.walletHoldId()))throw conflict("仓租冻结凭据不一致"); + if(success)switch(event.operation()){ + case "FREEZE"->{if(!"FROZEN".equals(event.holdStatus())||event.walletHoldId()==null)throw conflict("仓租冻结凭据不完整");rent.setHoldId(event.walletHoldId());funding.setWalletHoldId(event.walletHoldId());funding.getCommandHistory().put("holdAmount",event.amount().toPlainString());rent.setStatus("PENDING_REVIEW");funding.setWalletStatus("FROZEN");} + case "SETTLE_ACTUAL"->{ + if(!"APPROVED".equals(fee.getReviewStatus())||!"SETTLED".equals(event.holdStatus())||event.walletLedgerId()==null)throw conflict("仓租尚未审核或实扣凭据不完整"); + fee.setWalletSettlement(new WalletSettlementReference(event.commandEventId(),event.walletHoldId(),event.walletLedgerId(),"STORAGE_RENT",rent.getId().toString())); + fee.setPublicationStatus("PUBLISHED");rent.setStatus("SETTLED");rent.setLedgerId(event.walletLedgerId());funding.setWalletLedgerId(event.walletLedgerId());funding.setWalletStatus("SETTLED"); + } + case "RELEASE"->{if(!"RELEASED".equals(event.holdStatus()))throw conflict("仓租解冻凭据不完整");rent.setReleaseStatus("SUCCEEDED");funding.setWalletStatus("RELEASED"); + if("APPROVED".equals(fee.getReviewStatus())&&fee.getAmount().signum()==0){rent.setStatus("SETTLED");fee.setPublicationStatus("PUBLISHED");funding.setWalletStatus("SETTLED");}} + default->throw conflict("仓租资金操作无效"); + }else{funding.setWalletStatus(("SETTLE_ACTUAL".equals(event.operation())?"SETTLE":event.operation())+"_FAILED"); + if("SETTLE_ACTUAL".equals(event.operation()))rent.setStatus("SETTLE_FAILED"); + if("FREEZE".equals(event.operation()))rent.setStatus("REVIEW_FREEZE_FAILED"); + if("RELEASE".equals(event.operation()))rent.setReleaseStatus("FAILED");} + funding.getCommandHistory().put(event.commandEventId(),fingerprint);funding.setErrorMessage(success?null:event.errorMessage());rent.setErrorMessage(success?null:event.errorMessage());save(rent,fee,funding); + } + + /** 已知失败才产生新资金版本,不重放库存或重建费用;待审核冻结不因超时释放。 */ + public void recover(StorageRentPreparation rent){ + var fee=requiredFee(rent);var funding=fees.lockFunding(fee.getSourceFeeId()); + if("SETTLE_FAILED".equals(funding.getWalletStatus())&&"APPROVED".equals(fee.getReviewStatus())){rent.setStatus("CONSUMED");send(rent,funding,"SETTLE_ACTUAL");} + else if("RELEASE_FAILED".equals(funding.getWalletStatus()))send(rent,funding,"RELEASE"); + else if("FREEZE_FAILED".equals(funding.getWalletStatus())&&"PENDING".equals(fee.getReviewStatus())){rent.setStatus("REVIEW_FREEZE_PENDING");send(rent,funding,"FREEZE");} + else return; + save(rent,fee,funding); + } + + /** 同一仓租复用原冻结;释放必须提交原冻结额,实扣提交审核后的实际金额。 */ + private void send(StorageRentPreparation rent,OverseasFeeFunding funding,String operation){ + long version=Math.addExact(rent.getWalletVersion(),1);rent.setWalletVersion(version); + var amount="RELEASE".equals(operation)?null:rent.getAmount(); + String event=wallet.publish(operation,Long.parseLong(rent.getCustomerId()),"STORAGE_RENT",rent.getId().toString(),amount,"海外仓租审核结算",version); + funding.setCommandEventId(event);funding.setCommandVersion(version);funding.getCommandHistory().put(event,operation); + funding.setWalletStatus(("SETTLE_ACTUAL".equals(operation)?"SETTLE":operation)+"_PENDING");funding.setErrorMessage(null); + if("RELEASE".equals(operation)){rent.setReleaseEventId(event);rent.setReleaseStatus("PENDING");} + else if("FREEZE".equals(operation))rent.setFreezeEventId(event);else rent.setSettleEventId(event); + } + /** 写入口统一加锁顺序并核对费用来源,拒绝旧已扣费用进入新审核流程。 */ + private StorageRentPreparation requiredRent(long id){ + // 资金主键定位原预占主键,避免按无索引的费用引用扫描并锁住其他客户仓租。 + var state=fees.funding(id); + if(state==null||state.getWalletBusinessNo()==null||!state.getWalletBusinessNo().matches("[1-9][0-9]{0,18}"))throw conflict("没有对应的出库仓租"); + var rent=rents.lockPreparation(Long.parseLong(state.getWalletBusinessNo())); + if(rent==null||rent.getConsumedAt()==null||!Objects.equals(rent.getSourceFeeId(),id))throw conflict("没有对应的出库仓租"); + return rent; + } + /** 仓租不能借用其他客户或项目的费用来源。 */ + private OverseasFeeSource requiredFee(StorageRentPreparation rent){var fee=fees.lockFee(rent.getSourceFeeId());if(fee==null||!isRent(fee)||!Objects.equals(fee.getCustomerId(),rent.getCustomerId()))throw conflict("仓租费用来源不一致");return fee;} + /** 状态、费用和资金占用在调用方同一事务内提交。 */ + private void save(StorageRentPreparation rent,OverseasFeeSource fee,OverseasFeeFunding funding){rent.setUpdatedAt(now());rents.save(rent);fee.setUpdatedAt(now());fees.updateFee(fee);funding.setUpdatedAt(now());fees.updateFunding(funding);} + /** 幂等签名包含费用身份,同键不能修改审核决定、金额或原因。 */ + private boolean replayed(String operator,String action,String key,long id,Object request){var prior=fees.command(operator,action,key);if(prior==null)return false;if(!Objects.equals(prior.getSourceFeeId(),id)||!Objects.equals(prior.getRequestHash(),hash(request)))throw conflict("操作标识已用于不同仓租请求");return true;} + /** 保存每次审核和调整的独立操作标识,版本记录保留调整前后的金额和说明。 */ + private void record(String operator,String action,String key,long id,Object request,int version){var command=new OverseasFeeCommand();command.setId(IdWorker.getId());command.setOperatorId(operator);command.setAction(action);command.setIdempotencyKey(key);command.setSourceFeeId(id);command.setSourceVersion(version);command.setRequestHash(hash(request));command.setCreatedAt(now());fees.insertCommand(command);} + /** 已保存的原批次计费依据不可重新定价。 */ + private Plan plan(StorageRentPreparation rent){try{return json.readValue(rent.getPlanJson(),Plan.class);}catch(Exception failure){throw new IllegalStateException("仓租快照读取失败",failure);}} + /** 对持久化业务请求计算稳定签名,不接受内容变化的重试。 */ + private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}catch(Exception failure){throw new IllegalStateException(failure);}} + /** 仓租新记录统一保存 UTC。 */ + private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);} + /** 状态冲突交由统一接口协议返回。 */ + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java new file mode 100644 index 000000000..a90d731d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java @@ -0,0 +1,480 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.common.event.WalletSettlementReference; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.wallet.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.*; +import java.time.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +/** 实收计租、资金预占、扫码扣减与财务已扣来源发布的同源事务编排。 */ +@Service +public class StorageRentService implements WalletResultHandler { + @Resource private StorageRentRepository repository; + @Resource private StorageRentSourceResolver resolver; + @Resource private WalletCommandOutboxService wallet; + @Resource private OverseasFeeCurrencyConverter converter; + @Resource private OverseasFeeSourceService feeSources; + @Resource private ObjectMapper json; + @Resource private StorageRentReviewService rentReview; + @Value("${baiyue.storage-rent.legacy-return-count-receipt-nos:}") private String legacyReturnCountReceiptNos=""; + @Value("${baiyue.storage-rent.legacy-return-time-zone:}") private String legacyReturnTimeZone=""; + private static final String WALLET_TYPE="STORAGE_RENT"; + + /** 仅自有仓目的地头程和仓储转运入库计租,普通直送货物不产生仓租。 */ + public static boolean eligible(LastlegOrder order) { + return order!=null && ("WAREHOUSE_TRANSFER".equals(order.getOrderType()) + || "FIRST_LEG".equals(order.getOrderType())&&"OVERSEAS".equals(order.getDestinationType())); + } + /** 旧管理端直出路径遇到计租来源必须改走 PDA,不能跳过资金冻结门禁。 */ + public void requirePdaOutbound(String customer,String warehouse,Long positionId){ + var position=repository.position(positionId);if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId()))throw conflict("出库库存不属于当前客户和仓库"); + var balance=repository.balance(position.getBalanceId()); + if(!repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo()).isEmpty()||balance!=null&&eligible(repository.order(balance.getInboundOrderId())))throw conflict("计租库存请关联出库单并通过 PDA 扫码出库"); + } + /** 头程同单同仓合并计租,其余业务按实际实收凭证分别起算库龄。 */ + @Transactional + public void registerInbound(InboundStockReceipt receipt, LastlegOrder order) { + if(!eligible(order))return; + if("FIRST_LEG".equals(order.getOrderType())){registerFirstLegInbound(receipt,order);return;} + String key="INBOUND:"+receipt.getId(); if(repository.source(key)!=null)return; + var lot=base(key,order.getCustomerId(),order.getCustomerName(),order.getOverseasWarehouseId(),order.getOverseasWarehouseName(), + "LASTLEG_ORDER",order.getId(),order.getOrderNo(),order.getId(),receipt.getInboundCode(),receipt.getBatchNo(),receipt.getSkuCode(),LocalDateTime.now(ZoneOffset.UTC)); + var transfer=repository.transferByTail(order.getCustomerId(),order.getId()); + if(transfer!=null){lot.setBusinessType("WAREHOUSE_TRANSFER");lot.setBusinessId(transfer.getId());lot.setBusinessNo(transfer.getBusinessNo());} + lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT"); + lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedPackages()));lot.setRemainingQuantity(lot.getOriginalQuantity()); + // 一个订单的体积按原箱数分到每张实收凭证,不把整单 CBM 重复计入多个 SKU。 + lot.setOriginalUnits(order.getVolumeCbm()!=null&&order.getVolumeCbm().signum()>0&&order.getPackageCount()!=null&&order.getPackageCount()>0 + ? order.getVolumeCbm().multiply(lot.getOriginalQuantity()).divide(BigDecimal.valueOf(order.getPackageCount()),12,RoundingMode.HALF_UP):null); + repository.insert(lot);insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),lot.getOriginalQuantity()); + } + /** 头程补入库只增加原批次数量,首次起点、既有出库及计费单位比例保持不变。 */ + private void registerFirstLegInbound(InboundStockReceipt receipt,LastlegOrder order){ + // 订单行先串行化首次建批及补入库;登记表与库存份额同事务,重放不重复累计。 + var locked=repository.lockOrder(order.getId()); + if(locked==null||!eligible(locked)||!"FIRST_LEG".equals(locked.getOrderType()) + ||!Objects.equals(locked.getCustomerId(),order.getCustomerId()) + ||!Objects.equals(locked.getOverseasWarehouseId(),order.getOverseasWarehouseId()))throw conflict("头程计租订单归属已变化,请刷新后重试"); + if(repository.inboundRentLot(receipt.getId())!=null)return; + if(receipt.getReceivedPackages()==null||receipt.getReceivedPackages()<=0)throw conflict("计租实收箱数必须大于零"); + String key="FIRST_LEG:"+locked.getId()+":"+locked.getOverseasWarehouseId(); + var lot=repository.lockSource(key);var quantity=BigDecimal.valueOf(receipt.getReceivedPackages()); + if(lot==null){ + lot=base(key,locked.getCustomerId(),locked.getCustomerName(),locked.getOverseasWarehouseId(),locked.getOverseasWarehouseName(), + "LASTLEG_ORDER",locked.getId(),locked.getOrderNo(),locked.getId(),receipt.getInboundCode(),locked.getOrderNo(),null,LocalDateTime.now(ZoneOffset.UTC)); + lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT"); + lot.setOriginalQuantity(quantity);lot.setRemainingQuantity(quantity); + lot.setOriginalUnits(locked.getVolumeCbm()!=null&&locked.getVolumeCbm().signum()>0&&locked.getPackageCount()!=null&&locked.getPackageCount()>0 + ?locked.getVolumeCbm().multiply(quantity).divide(BigDecimal.valueOf(locked.getPackageCount()),12,RoundingMode.HALF_UP):null); + repository.insert(lot); + }else{ + // 不能重取改单后的整单体积,也不能将累计实收直接覆盖剩余箱数。 + if(lot.getOriginalUnits()!=null)lot.setOriginalUnits(lot.getOriginalUnits().add( + lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP))); + lot.setOriginalQuantity(lot.getOriginalQuantity().add(quantity)); + lot.setRemainingQuantity(lot.getRemainingQuantity().add(quantity)); + lot.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(lot); + } + // 份额可能已被全部出库为零,补货时仍复用原行,不能受剩余量过滤影响。 + var share=repository.lockStockShare(lot.getId(),receipt.getInventoryBalanceId(),receipt.getBatchNo()); + if(share==null)insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),quantity); + else{share.setQuantity(share.getQuantity().add(quantity));repository.save(share);} + repository.recordInboundRent(receipt.getId(),lot.getId(),quantity); + } + /** 外来转运实收采用测量后的该来源体积,避免等待客户确认时重置库龄。 */ + @Transactional + public void registerTransfer(InboundStockReceipt receipt, com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder transfer, + BigDecimal volume, String warehouseName) { + String key="INBOUND:"+receipt.getId();if(repository.source(key)!=null)return; + var order=repository.order(transfer.getLastlegOrderId()); + var lot=base(key,transfer.getCustomerId(),order==null?null:order.getCustomerName(),transfer.getOperatingWarehouseId(),warehouseName, + "WAREHOUSE_TRANSFER",transfer.getId(),transfer.getTransferNo(),transfer.getLastlegOrderId(),receipt.getInboundCode(),receipt.getBatchNo(),receipt.getSkuCode(),LocalDateTime.now(ZoneOffset.UTC)); + lot.setBalanceId(receipt.getInventoryBalanceId());lot.setRuleCode("TRANSIT");lot.setOriginalUnits(volume); + lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedPackages()));lot.setRemainingQuantity(lot.getOriginalQuantity());repository.insert(lot); + insertStockShare(lot,receipt.getInventoryBalanceId(),receipt.getBatchNo(),lot.getOriginalQuantity()); + } + /** 每张原始退货签收托为一个计费原托,箱内份额随实际商品数量分配。 */ + @Transactional + public void registerReturn(ReturnOrder order, ReturnReceipt receipt) { + String key="RETURN:"+receipt.getId();if(repository.source(key)!=null)return; + var tail=repository.order(order.getLastlegOrderId()); + var lot=base(key,order.getCustomerId(),tail==null?null:tail.getCustomerName(),order.getOverseasWarehouseId(),tail==null?null:tail.getOverseasWarehouseName(), + "RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,receipt.getReceiptBatchNo(),null,LocalDateTime.now(ZoneOffset.UTC)); + lot.setRuleCode("RETURN");lot.setOriginalUnits(BigDecimal.ONE); + lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedQuantity()));lot.setRemainingQuantity(lot.getOriginalQuantity());repository.insert(lot); + rebuildReturnShares(receipt.getId()); + } + /** 首次清点只重分箱内商品归属,不能改变原托时间、托数或商品总量。 */ + @Transactional + public void rebuildReturnShares(long receiptId) { + var found=repository.source("RETURN:"+receiptId);if(found==null)throw conflict("退货签收缺少计租原批次"); + var lot=repository.lockLot(found.getId()); + if(lot.getOriginalQuantity().compareTo(lot.getRemainingQuantity())!=0)throw conflict("原批次已出库,不能重新分配计租份额"); + var cartons=repository.receiptCartons(receiptId);var newShares=new ArrayList();BigDecimal total=BigDecimal.ZERO; + for(var carton:cartons)for(var item:repository.items(carton.getId())){ + var share=newShare(lot,carton.getCartonMark(),item.getSkuCode(),BigDecimal.valueOf(item.getQuantity())); + newShares.add(share);total=total.add(share.getQuantity()); + } + if(total.compareTo(lot.getOriginalQuantity())!=0)throw conflict("清点商品数量与原计租批次不一致"); + repository.deleteShares(lot.getId());newShares.forEach(repository::insert); + } + /** 仅显式指定的历史签收可在正常清点事务内恢复缺失计租批次,新单缺失继续阻断。 */ + @Transactional + public void ensureReturnCountLot(long receiptId) { + if(repository.source("RETURN:"+receiptId)!=null)return; + var receipt=repository.lockReturnReceipt(receiptId); + var allowed=Arrays.stream(legacyReturnCountReceiptNos.split(",")).map(String::trim).filter(v->!v.isBlank()).toList(); + if(receipt==null||!allowed.contains(receipt.getReceiptNo()))throw conflict("退货签收缺少计租原批次"); + ZoneId zone;try{if(legacyReturnTimeZone.isBlank())throw new IllegalArgumentException();zone=ZoneId.of(legacyReturnTimeZone);}catch(Exception invalid){throw conflict("历史签收时区未核实,不能恢复计租批次");} + if(!"SIGNED".equals(receipt.getStatus())||!"PALLET".equals(receipt.getUnitType())||receipt.getSignedAt()==null||receipt.getReturnOrderId()==null + ||receipt.getReceivedQuantity()==null||receipt.getReceivedQuantity()<=0||receipt.getTotalBoxCount()==null||receipt.getTotalBoxCount()<=0 + ||receipt.getPalletCode()==null||receipt.getPalletCode().isBlank()||!Objects.equals(receipt.getPalletCode(),receipt.getUnitCode()))throw conflict("历史签收原托和数量凭证不完整"); + var offsets=zone.getRules().getValidOffsets(receipt.getSignedAt());if(offsets.size()!=1)throw conflict("历史签收时间存在时区歧义"); + var signedUtc=receipt.getSignedAt().atOffset(offsets.get(0)).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(); + if(signedUtc.isAfter(LocalDateTime.now(ZoneOffset.UTC)))throw conflict("历史签收时间不能在未来"); + var order=repository.returnOrder(receipt.getReturnOrderId()); + if(order==null||!Objects.equals(order.getCustomerId(),receipt.getCustomerId())||order.getOverseasWarehouseId()==null||order.getRelabelApplicationId()!=null)throw conflict("历史签收业务归属或换标状态不满足恢复条件"); + var batches=repository.returnReceiptBatches(receiptId); + if(batches.size()!=1)throw conflict("历史签收计费起点凭证不完整");var batch=batches.get(0); + if(!Objects.equals(batch.getCustomerId(),receipt.getCustomerId())||!Objects.equals(batch.getReturnOrderId(),order.getId()) + ||!Objects.equals(batch.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!Objects.equals(batch.getBatchNo(),receipt.getReceiptBatchNo()) + ||!Objects.equals(batch.getStorageStartedAt(),receipt.getSignedAt())||!"PENDING_PRICING".equals(batch.getStorageBillingStatus()))throw conflict("历史签收计费起点凭证与原签收不一致"); + var expected=new TreeMap();for(var line:repository.returnReceiptItems(receiptId)){ + if(!Objects.equals(line.getCustomerId(),receipt.getCustomerId())||line.getSkuCode()==null||line.getReceivedQuantity()==null||line.getReceivedQuantity()<=0)throw conflict("历史签收原商品数量凭证不完整"); + expected.merge(line.getSkuCode(),line.getReceivedQuantity(),Math::addExact);} + if(!Objects.equals(expected.values().stream().reduce(0L,Math::addExact),receipt.getReceivedQuantity()))throw conflict("历史签收原商品总量不一致"); + var boxes=repository.receiptCartons(receiptId);if(boxes.size()!=receipt.getTotalBoxCount())throw conflict("历史签收原箱数量不完整"); + var actual=new TreeMap(); + for(var box:boxes){ + if(!Objects.equals(box.getCustomerId(),receipt.getCustomerId())||!Objects.equals(box.getOverseasWarehouseId(),order.getOverseasWarehouseId()) + ||!Objects.equals(box.getSourceReceiptId(),receiptId)||!Objects.equals(box.getPalletCode(),receipt.getPalletCode())||!"PALLET".equals(box.getSourceUnitType()) + ||!Objects.equals(box.getSourceBatchNo(),receipt.getReceiptBatchNo())||!Objects.equals(box.getSourceReturnNo(),order.getReturnNo())||!Objects.equals(box.getSourceUnitCode(),receipt.getUnitCode()) + ||!"PENDING".equals(box.getCountStatus())||!"AVAILABLE".equals(box.getStatus())||box.getRelabelApplicationId()!=null||box.getRelabelNo()!=null + ||box.getCountedAt()!=null||box.getCountedBy()!=null||box.getRelabelCompletedAt()!=null + ||box.getMergedAt()!=null||box.getNewCartonMark()!=null||!repository.shares(receipt.getCustomerId(),order.getOverseasWarehouseId(),box.getCartonMark(),null).isEmpty())throw conflict("历史签收箱已清点、换标、合箱或发生其他作业,不能恢复原托"); + long total=0;for(var item:repository.items(box.getId())){ + if(!Objects.equals(item.getCustomerId(),receipt.getCustomerId())||item.getSkuCode()==null||item.getQuantity()==null||item.getQuantity()<=0 + ||item.getPackingReservedQuantity()!=null&&item.getPackingReservedQuantity()>0)throw conflict("历史签收箱商品或预留状态不满足恢复条件"); + actual.merge(item.getSkuCode(),item.getQuantity(),Math::addExact);total=Math.addExact(total,item.getQuantity());} + if(!Objects.equals(box.getTotalQuantity(),total))throw conflict("历史签收箱商品总量不一致"); + } + if(!expected.equals(actual))throw conflict("历史签收商品已有变化,不能恢复原托计租"); + var tail=repository.order(order.getLastlegOrderId()); + var lot=base("RETURN:"+receiptId,receipt.getCustomerId(),tail==null?null:tail.getCustomerName(),order.getOverseasWarehouseId(),tail==null?null:tail.getOverseasWarehouseName(), + "RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,receipt.getReceiptBatchNo(),null,signedUtc); + lot.setRuleCode("RETURN");lot.setOriginalUnits(BigDecimal.ONE);lot.setOriginalQuantity(BigDecimal.valueOf(receipt.getReceivedQuantity()));lot.setRemainingQuantity(lot.getOriginalQuantity()); + // 恢复创建时间记录本次正式事务,计租起点始终保留原签收时刻,不生成免租或伪造资金流水。 + lot.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));lot.setUpdatedAt(lot.getCreatedAt());repository.insert(lot); + } + /** 合箱按明确原商品份额搬移,不创建新托、不重新开始免费期。 */ + @Transactional + public void moveReturnShares(String customer,String warehouse,String source,String target,String sku,long quantity) { + if(Objects.equals(source,target))return; + var available=repository.shares(customer,warehouse,source,sku); + var total=available.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add); + var requested=BigDecimal.valueOf(quantity);if(total.compareTo(requested)<0)throw conflict("合箱来源缺少原计租份额"); + // 同箱同 SKU 混有多个原托时按数量比例保留各原托归属,不猜 FIFO 来源。 + BigDecimal allocated=BigDecimal.ZERO; + for(int i=0;is.getLotId().equals(share.getLotId())).findFirst().orElse(null); + if(targetShare==null)repository.insert(newShare(repository.lockLot(share.getLotId()),target,sku,moved)); + else {targetShare.setQuantity(targetShare.getQuantity().add(moved));repository.save(targetShare);} + } + } + /** SKU 转换保留原租批次;精确迁移快照供审核退回恢复,避免混合目标 SKU 改变原托归属。 */ + @Transactional + public String convertReturnSkuShares(String customer,String warehouse,String mark,String oldSku,String newSku,long quantity) { + var source=repository.shares(customer,warehouse,mark,oldSku);var total=source.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal requested=BigDecimal.valueOf(quantity),used=BigDecimal.ZERO;if(quantity<=0||total.compareTo(requested)<0)throw conflict("换标来源缺少原计租份额"); + var movements=new ArrayList(); + for(int i=0;i movements;try{movements=json.readValue(snapshot,new com.fasterxml.jackson.core.type.TypeReference>(){});}catch(Exception failure){throw conflict("换标原计租份额快照不完整");} + for(var movement:movements){var sources=repository.shares(customer,warehouse,mark,newSku).stream().filter(v->v.getLotId().equals(movement.lotId())).toList(); + var total=sources.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(movement.quantity())<0)throw conflict("换标原计租份额已出库,不能退回"); + var remaining=movement.quantity();for(var share:sources){var amount=share.getQuantity().min(remaining);share.setQuantity(share.getQuantity().subtract(amount));repository.save(share);remaining=remaining.subtract(amount);if(remaining.signum()==0)break;} + addReturnSkuShare(customer,warehouse,mark,oldSku,movement.lotId(),movement.quantity());} + } + /** 同箱同 SKU 的原批次合并份额,原托时间与计费基数始终只在 lot 保存。 */ + private void addReturnSkuShare(String customer,String warehouse,String mark,String sku,long lotId,BigDecimal quantity){ + var target=repository.shares(customer,warehouse,mark,sku).stream().filter(v->v.getLotId().equals(lotId)).findFirst().orElse(null); + if(target==null)repository.insert(newShare(repository.lockLot(lotId),mark,sku,quantity));else{target.setQuantity(target.getQuantity().add(quantity));repository.save(target);} + } + /** 一次 SKU 转换实际迁移的原托数量份额。 */ + private record SkuMovement(Long lotId,BigDecimal quantity) { } + /** 换标变更 SKU 或批次只搬移原库存份额,不启动新库龄、不重复创建原托。 */ + @Transactional + public void moveStockShares(String customer,String warehouse,long oldBalance,String oldBatch, + long newBalance,String newBatch,long quantity) { + if(oldBalance==newBalance&&Objects.equals(oldBatch,newBatch))return; + var source=repository.stockShares(customer,warehouse,oldBalance,oldBatch); + if(source.isEmpty()){ + var old=repository.balance(oldBalance);if(old!=null&&eligible(repository.order(old.getInboundOrderId())))throw conflict("换标来源缺少原计租批次");return; + } + var total=source.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal requested=BigDecimal.valueOf(quantity),used=BigDecimal.ZERO;if(total.compareTo(requested)<0)throw conflict("换标来源计租数量不足"); + for(int i=0;is.getLotId().equals(share.getLotId())).findFirst().orElse(null); + if(target==null)insertStockShare(repository.lockLot(share.getLotId()),newBalance,newBatch,part); + else{target.setQuantity(target.getQuantity().add(part));repository.save(target);}} + } + /** 提交预校验与冻结 Outbox 后正常返回,待回执阶段不能以抛错回滚冻结命令。 */ + @Transactional + public PreparationResult prepare(String warehouse,PrepareRequest request) { + validateRequest(request);String hash=hash(request);var old=repository.latest(warehouse,request.mode(),request.idempotencyKey()); + if(old!=null&&!old.getRequestHash().equals(hash))throw conflict("扫码幂等键已用于不同的货物或数量"); + if(old!=null&&old.getConsumedAt()!=null)return result(old); + var now=LocalDateTime.now(ZoneOffset.UTC); + boolean reuse=old!=null&&Set.of("FREEZE_PENDING","READY").contains(old.getStatus())&&old.getCreatedAt().isAfter(now.minusMinutes(15)); + var plan=reuse?resolver.resolve(warehouse,request,now,read(old).pricing()):resolver.resolve(warehouse,request,now); + if(plan.allocations().isEmpty())return new PreparationResult(null,"NOT_REQUIRED",true,BigDecimal.ZERO,"CNY",BigDecimal.ZERO,plan.pricing()==null?null:plan.pricing().currency(),null,null,null); + if(old!=null&&Set.of("FREEZE_PENDING","READY").contains(old.getStatus())){ + if(old.getCreatedAt().isAfter(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(15))&&sameBasis(read(old),plan)&&plan.amount().compareTo(old.getOriginalAmount())<=0)return result(old); + release(old,"EXPIRED"); + // 重建尝试才读取客户新配置;有效的重复请求和原扫码消费都使用旧快照。 + if(reuse)plan=resolver.resolve(warehouse,request,now); + } + if(plan.owner()==null||plan.owner().businessId()==null||plan.owner().businessNo()==null)throw conflict("计租货物缺少对应业务单,不能出库"); + // 免费批次仍保存出库计租记录;没有应收金额时不要求汇率,不发布资金指令。 + var conversion=plan.amount().signum()==0 + ?new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),BigDecimal.ZERO.setScale(2),null,plan.calculatedAt().toLocalDate().toString().substring(0,7),BigDecimal.ZERO.setScale(2)) + :converter.convert(plan.pricing().currency(),plan.amount(),plan.calculatedAt().toLocalDate()); + var value=new StorageRentPreparation();value.setId(IdWorker.getId());value.setWarehouseId(warehouse);value.setCustomerId(plan.owner().customerId()); + value.setMode(request.mode());value.setExecutionNo(request.businessNo());value.setRequestKey(request.idempotencyKey());value.setRequestHash(hash); + value.setAttempt(old==null?1:Math.addExact(old.getAttempt(),1));value.setBusinessType(plan.owner().businessType());value.setBusinessId(plan.owner().businessId());value.setBusinessNo(plan.owner().businessNo()); + value.setPlanJson(write(plan));value.setOriginalAmount(conversion.originalAmount());value.setAmount(conversion.settlementAmount()); + value.setExchangeRate(conversion.exchangeRate());value.setExchangeRateMonth(conversion.exchangeRateMonth());value.setCreatedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt()); + value.setWalletVersion(3L);value.setStatus(value.getAmount().signum()==0?"READY":"FREEZE_PENDING"); + try{repository.insert(value);}catch(DuplicateKeyException concurrent){var winner=repository.latest(warehouse,request.mode(),request.idempotencyKey());if(winner==null||!winner.getRequestHash().equals(hash))throw concurrent;return result(winner);} + if(value.getAmount().signum()>0){value.setFreezeEventId(wallet.publish("FREEZE",customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租预占",1));repository.save(value);} + return result(value); + } + /** 原始扫码事务内校验成功冻结和同一来源份额,任何异常使库存与租金一并回滚。 */ + @Transactional + public void consume(String warehouse,PrepareRequest request) { + var value=repository.latest(warehouse,request.mode(),request.idempotencyKey()); + var plan=value==null?resolver.resolve(warehouse,request,LocalDateTime.now(ZoneOffset.UTC)) + :resolver.resolve(warehouse,request,LocalDateTime.now(ZoneOffset.UTC),read(value).pricing()); + if(plan.allocations().isEmpty())return; + if(value==null||!value.getRequestHash().equals(hash(request)))throw conflict("请先完成本次扫码的仓租预校验"); + if(value.getConsumedAt()!=null)throw conflict("本仓租扫码已扣减,请恢复原扫码结果"); + if(!"READY".equals(value.getStatus())||value.getCreatedAt().isBefore(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(15))||value.getAmount().signum()>0&&value.getHoldId()==null)throw conflict("仓租资金未冻结成功或预占已过期,请重新预校验"); + var saved=read(value); + if(!sameBasis(saved,plan)||plan.amount().compareTo(value.getOriginalAmount())>0)throw conflict("仓租计费时间或货物已变化,请重新预校验"); + // 先按批次排序锁定,再逐份扣减,资金与实际库存使用调用者同一事务。 + for(var allocation:saved.allocations().stream().sorted(Comparator.comparingLong(Allocation::lotId)).toList()){ + var lot=repository.lockLot(allocation.lotId()); + if(lot==null||!value.getCustomerId().equals(lot.getCustomerId())||lot.getRemainingQuantity().compareTo(allocation.quantity())<0)throw conflict("原计租批次剩余数量已变化"); + if(allocation.shareId()!=null){var share=repository.lockShare(allocation.shareId()); + if(share==null||share.getQuantity().compareTo(allocation.quantity())<0)throw conflict("退货计租商品份额已变化"); + share.setQuantity(share.getQuantity().subtract(allocation.quantity()));repository.save(share);} + lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(allocation.quantity()));lot.setUpdatedAt(plan.calculatedAt());repository.save(lot); + } + value.setConsumedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt()); + // 出库只生成待审核费用并沿用原冻结;审核通过后才发送真实扣款指令。 + rentReview.create(value,feeRequest(value,saved),saved); + repository.save(value); + } + /** 取消扫描仅解冻未使用记录,已扣减的库存必须沿原业务撤销流程处理。 */ + @Transactional + public PreparationResult cancel(String warehouse,long id) {var value=owned(warehouse,id);if(value.getConsumedAt()!=null)throw conflict("已出库仓租不能取消预占");release(value,"CANCELLED");return result(value);} + /** 原工单取消后释放其所有未使用冻结,回执迟到也按取消态解冻。 */ + @Transactional + public void cancelExecution(String mode,String execution) {for(var value:repository.pending(mode,execution))release(value,"CANCELLED");} + /** 查询资金状态仍限制在 PDA 实际绑定仓。 */ + @Transactional + public PreparationResult preparation(String warehouse,long id) {return result(owned(warehouse,id));} + /** 资金消费者仅路由仓租专用业务身份。 */ + @Override public boolean supports(String type) {return WALLET_TYPE.equals(type);} + /** 每次回执校验原指令、客户、金额和冻结身份,幂等更新不重复生成费用。 */ + @Override @Transactional + public void handle(WalletCommandCompletedV1 event) { + if(!supports(event.businessType()))return; + var value=repository.lockPreparation(Long.parseLong(event.businessNo()));if(value==null)throw conflict("仓租资金来源不存在"); + if(value.getConsumedAt()!=null&&rentReview.managed(value)){rentReview.handle(value,event);return;} + if(customer(value)!=event.customerId()||event.amount()==null||event.amount().compareTo(value.getAmount())!=0)throw conflict("仓租资金回执客户或金额不一致"); + if(!("SUCCEEDED".equals(event.result())||"FAILED".equals(event.result()))||event.completedAt()==null)throw conflict("仓租资金回执结果不完整"); + boolean success="SUCCEEDED".equals(event.result()); + switch(event.operation()){ + case "FREEZE" -> { + if(!Objects.equals(value.getFreezeEventId(),event.commandEventId()))throw conflict("仓租冻结回执指令不一致"); + if(!Set.of("FREEZE_PENDING","EXPIRED","CANCELLED").contains(value.getStatus()))return; + if(success&&!"FROZEN".equals(event.holdStatus()))throw conflict("仓租冻结结果缺少有效冻结状态"); + if(success){if(event.walletHoldId()==null)throw conflict("仓租冻结结果缺少资金占用凭据");value.setHoldId(event.walletHoldId()); + if(Set.of("EXPIRED","CANCELLED").contains(value.getStatus()))release(value,value.getStatus());else value.setStatus("READY");} + else if("FREEZE_PENDING".equals(value.getStatus()))value.setStatus("FAILED"); + } + case "SETTLE" -> { + if(!Objects.equals(value.getSettleEventId(),event.commandEventId())||success&&!Objects.equals(value.getHoldId(),event.walletHoldId()))throw conflict("仓租扣款回执来源不一致"); + if("SETTLED".equals(value.getStatus()))return; + if(value.getConsumedAt()==null)throw conflict("未出库仓租不能扣款"); + if(success){if(!"SETTLED".equals(event.holdStatus())||event.walletLedgerId()==null)throw conflict("仓租扣款凭据不完整"); + value.setLedgerId(event.walletLedgerId());var plan=read(value);var request=feeRequest(value,plan); + var converted=new OverseasFeeCurrencyConverter.Conversion(plan.pricing().currency(),value.getOriginalAmount(),value.getExchangeRate(),value.getExchangeRateMonth(),value.getAmount()); + var fee=feeSources.publishSettled("STORAGE_RENT",value.getId().toString(),request,converted,new WalletSettlementReference(event.commandEventId(),event.walletHoldId(),event.walletLedgerId(),"STORAGE_RENT",value.getId().toString())); + value.setSourceFeeId(fee.getSourceFeeId());value.setStatus("SETTLED"); + }else value.setStatus("SETTLE_FAILED"); + } + case "RELEASE" -> {if(!Objects.equals(value.getReleaseEventId(),event.commandEventId())||success&&!Objects.equals(value.getHoldId(),event.walletHoldId()))throw conflict("仓租解冻回执指令不一致"); + if(success&&!"RELEASED".equals(event.holdStatus()))throw conflict("仓租解冻结果状态不完整");value.setReleaseStatus(success?"SUCCEEDED":"FAILED");} + default -> throw conflict("仓租资金操作无效"); + } + value.setErrorMessage(success?null:event.errorMessage());value.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(value); + } + /** 列表只展示一个原始批次一行,不重复展示订单详情字段。 */ + public PageResult list(Query query) { + long page=query.page()==null?1:query.page(),size=query.size()==null?20:query.size(); + if(page<1||size<1||size>200)throw conflict("分页参数无效");var result=repository.page(query,page,size); + var charges=repository.charges(result.getRecords().stream().map(StorageRentLot::getId).toList()); + var settled=new HashMap();var counts=new HashMap();var settledCounts=new HashMap(); + var generated=new HashMap>();var lastOutbound=new HashMap(); + for(var value:charges){var amounts=lotAmounts(value,value.getOriginalAmount());for(var entry:amounts.entrySet()){ + counts.merge(entry.getKey(),1L,Long::sum); + // 仅成功扣款回执计入已扣款,处理中和失败费用仍保留原币应收。 + if("SETTLED".equals(value.getStatus())){ + settled.merge(entry.getKey(),lotAmounts(value,value.getAmount()).get(entry.getKey()),BigDecimal::add); + settledCounts.merge(entry.getKey(),1L,Long::sum); + } + generated.computeIfAbsent(entry.getKey(),key->new TreeMap<>()).merge(read(value).pricing().currency(),entry.getValue(),BigDecimal::add); + if(value.getConsumedAt()!=null)lastOutbound.merge(entry.getKey(),value.getConsumedAt(),(a,b)->a.isAfter(b)?a:b); + } + } + return new PageResult<>(result.getRecords().stream().map(lot->view(lot,generated.getOrDefault(lot.getId(),Map.of()),lastOutbound.get(lot.getId()),settled.getOrDefault(lot.getId(),BigDecimal.ZERO.setScale(2)),counts.getOrDefault(lot.getId(),0L),settledCounts.getOrDefault(lot.getId(),0L))).toList(),page,size,result.getTotal(),result.getPages()); + } + /** 批次详情包含出库应收与资金状态,可追溯到原始扫码。 */ + public LotDetail detail(long id) {var lot=repository.lot(id);if(lot==null)throw missing("仓租批次不存在");var charges=repository.charges(id).stream().map(v->charge(v,id)).toList();var last=charges.stream().map(ChargeView::outboundAt).filter(Objects::nonNull).max(Comparator.naturalOrder()).map(OffsetDateTime::toLocalDateTime).orElse(null);var generated=new TreeMap();for(var charge:charges)generated.merge(charge.currency(),charge.originalAmount(),BigDecimal::add);var settled=charges.stream().filter(c->"SETTLED".equals(c.status())).toList();return new LotDetail(view(lot,generated,last,settled.stream().map(ChargeView::settlementAmount).reduce(BigDecimal.ZERO.setScale(2),BigDecimal::add),charges.size(),settled.size()),charges);} + /** 只读仓库选项由仓库引用快照组成。 */ + public List warehouseOptions() {return repository.warehouseOptions();} + /** 将内部规则和字段转成前端稳定展示协议。 */ + private LotView view(StorageRentLot lot,Map generated,LocalDateTime lastOutbound,BigDecimal settledAmount,long chargeCount,long settledChargeCount) { + // 清仓后库龄固定在最后一次实际出库,查询时间不再增加已结清批次库龄。 + var end=lot.getRemainingQuantity().signum()==0&&lastOutbound!=null?lastOutbound:LocalDateTime.now(ZoneOffset.UTC); + long days=StorageRentCalculator.days(lot.getInboundAt(),end.isBefore(lot.getInboundAt())?lot.getInboundAt():end); + return new LotView(lot.getId(),lot.getCustomerId(),lot.getCustomerName(),lot.getBusinessType(),lot.getBusinessNo(),lot.getWarehouseId(),lot.getWarehouseName(),lot.getBatchNo(),utc(lot.getInboundAt()),days, + lot.getOriginalQuantity(),lot.getRemainingQuantity(),"RETURN".equals(lot.getRuleCode())?"PIECE":"PACKAGE",lot.getOriginalUnits(),"RETURN".equals(lot.getRuleCode())?"PALLET":"CBM",lot.getRuleCode()+"_V1",lot.getRemainingQuantity().signum()==0?"CLEARED":"IN_STOCK",generated.size()>1?null:generated.values().stream().findFirst().orElse(BigDecimal.ZERO.setScale(2)),generated.size()==1?generated.keySet().iterator().next():null,generated.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->new CurrencyAmount(e.getKey(),e.getValue())).toList(),settledAmount,chargeCount,settledChargeCount); + } + /** 混合批次同一扫码费用按原冻结原币份额呈现,扣款总额仍归原应收。 */ + private ChargeView charge(StorageRentPreparation value,long lotId) { + var allocations=read(value).allocations().stream().filter(a->a.lotId()==lotId).toList(); + BigDecimal amount=lotAmounts(value,value.getOriginalAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2)); + BigDecimal settlement=lotAmounts(value,value.getAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2)); + return new ChargeView(value.getId(),value.getExecutionNo(),value.getBusinessNo(),allocations.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add),allocations.stream().mapToLong(Allocation::days).max().orElse(0),amount,read(value).pricing().currency(),settlement,value.getExchangeRate(),value.getStatus(),utc(value.getCreatedAt()),utc(value.getConsumedAt())); + } + /** 原资金总额按批次稳定分配到分,末批承接尾差,确保多批显示合计等于实际扣款。 */ + private Map lotAmounts(StorageRentPreparation value,BigDecimal amount){ + var basis=new TreeMap();for(var a:read(value).allocations())basis.merge(a.lotId(),a.amount(),BigDecimal::add); + var total=basis.values().stream().reduce(BigDecimal.ZERO,BigDecimal::add); + // 原免租批次人工补收时按实际数量分摊,不能把非零费用汇总成零。 + if(total.signum()==0){basis.clear();for(var a:read(value).allocations())basis.merge(a.lotId(),a.quantity(),BigDecimal::add);total=basis.values().stream().reduce(BigDecimal.ZERO,BigDecimal::add);} + var result=new LinkedHashMap();BigDecimal used=BigDecimal.ZERO; + for(var entry:basis.entrySet()){var part=total.signum()==0?BigDecimal.ZERO.setScale(2):entry.getKey().equals(basis.lastKey())?amount.subtract(used):amount.multiply(entry.getValue()).divide(total,2,RoundingMode.DOWN);result.put(entry.getKey(),part);used=used.add(part);}return result; + } + /** 新仓租表统一记录 UTC,公开协议携带偏移,避免浏览器当成本地墙钟时间。 */ + private static OffsetDateTime utc(LocalDateTime value){return value==null?null:value.atOffset(ZoneOffset.UTC);} + /** 财务费用记本次服务单;余额授信应收不携带仅应付使用的供应商结算方式。 */ + private SaveRequest feeRequest(StorageRentPreparation value,Plan plan) { + var owner=plan.owner();String source=switch(owner.businessType()){case "LASTLEG_ORDER"->"OVERSEAS_ORDER_FEE";case "RELABEL_ORDER"->"OVERSEAS_RELABEL_FEE";default->"OVERSEAS_WAREHOUSE_FEE";}; + return new SaveRequest(owner.customerId(),owner.customerName(),source,owner.businessType(),owner.businessId(),owner.businessNo(),owner.orderId(),null,owner.inboundCode(),null,null,null,Long.valueOf(owner.warehouseId()), + "FEE_TYPE_077","仓储费", null,"RECEIVABLE",null,null,null,null,null,null,null,null, + plan.calculatedAt().toLocalDate(),plan.pricing().currency(),value.getOriginalAmount(),null,null,null, + plan.allocations().stream().map(Allocation::inboundAt).min(Comparator.naturalOrder()).orElseThrow().toLocalDate(),value.getConsumedAt().toLocalDate()); + } + /** 解冻命令只发布一次;冻结处理中取消时等待原冻结回执后补发解冻。 */ + private void release(StorageRentPreparation value,String status) { + if(value.getConsumedAt()!=null)return;value.setStatus(status); + if(value.getHoldId()!=null&&value.getReleaseEventId()==null&&value.getAmount().signum()>0){ + value.setReleaseStatus("PENDING");value.setReleaseEventId(wallet.publish("RELEASE",customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租取消预占",3));} + value.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));repository.save(value); + } + /** 恢复任务只读取需要处理的主键,避免持有大批资金行锁。 */ + public List recoveryCandidates(){var now=LocalDateTime.now(ZoneOffset.UTC);return repository.recoveryCandidates(now.minusMinutes(15),now.minusMinutes(1));} + /** 原已消费仓租只重试结算,未使用且过期的预占只释放资金。 */ + @Transactional + public void recover(long id){ + var value=repository.lockPreparation(id);if(value==null)return;var now=LocalDateTime.now(ZoneOffset.UTC); + if(value.getConsumedAt()!=null&&rentReview.managed(value)&&value.getUpdatedAt().isBefore(now.minusMinutes(1))){rentReview.recover(value);return;} + if(value.getConsumedAt()==null&&Set.of("FREEZE_PENDING","READY").contains(value.getStatus())&&value.getCreatedAt().isBefore(now.minusMinutes(15))){release(value,"EXPIRED");return;} + if(!value.getUpdatedAt().isBefore(now.minusMinutes(1)))return; + String operation; + if("SETTLE_FAILED".equals(value.getStatus())&&value.getConsumedAt()!=null){operation="SETTLE";value.setStatus("CONSUMED");} + else if("FAILED".equals(value.getReleaseStatus())&&value.getConsumedAt()==null){operation="RELEASE";value.setReleaseStatus("PENDING");} + else return; + long version=Math.addExact(value.getWalletVersion()==null?3:value.getWalletVersion(),1);value.setWalletVersion(version); + String event=wallet.publish(operation,customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租资金恢复",version); + if("SETTLE".equals(operation))value.setSettleEventId(event);else value.setReleaseEventId(event); + value.setErrorMessage(null);value.setUpdatedAt(now);repository.save(value); + } + /** 报价重新计算可降低金额,但不能增加已冻结金额或改变原商品份额。 */ + private static boolean sameBasis(Plan a,Plan b) { + if(!Objects.equals(a.owner(),b.owner())||a.allocations().size()!=b.allocations().size())return false; + for(int i=0;iof():r.cartonMarks().stream().filter(Objects::nonNull).sorted().toList(); + var lines=r.lines()==null?List.of():r.lines().stream().sorted(Comparator.comparing(com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos.ActualLine::itemId)).toList(); + var photos=r.photoFileIds()==null?List.of():r.photoFileIds().stream().sorted().toList(); + var scanned=r.scannedCartonMarks()==null?List.of():r.scannedCartonMarks().stream().sorted().toList(); + var parts=Arrays.asList(r.mode(),r.businessNo(),r.itemId(),r.positionId(),r.packages(),blank(r.cartonMark()),blank(r.palletNo()),r.idempotencyKey(),marks,blank(r.skuCode()),r.productQuantity(),lines,photos,scanned,Boolean.TRUE.equals(r.confirmAllCartons())); + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(parts).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException("仓租请求签名失败",e);}} + /** 空文本统一表达为未提交。 */ + private static String blank(String value){return value==null||value.isBlank()?null:value.trim();} + /** 原快照序列化失败必须回滚,不能静默丢失资金依据。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("仓租快照写入失败",e);}} + /** 回执只使用原确认的快照,不重新读取汇率或报价。 */ + private Plan read(StorageRentPreparation v){try{return json.readValue(v.getPlanJson(),Plan.class);}catch(Exception e){throw new IllegalStateException("仓租快照读取失败",e);}} + /** 原始批次通用身份快照。 */ + private static StorageRentLot base(String key,String customer,String name,String warehouse,String warehouseName,String type,Long businessId,String businessNo,Long orderId,String inboundCode,String batch,String sku,LocalDateTime at){ + var v=new StorageRentLot();v.setId(IdWorker.getId());v.setSourceKey(key);v.setCustomerId(customer);v.setCustomerName(name);v.setWarehouseId(warehouse);v.setWarehouseName(warehouseName);v.setBusinessType(type);v.setBusinessId(businessId);v.setBusinessNo(businessNo);v.setOrderId(orderId);v.setInboundCode(inboundCode);v.setBatchNo(batch);v.setSkuCode(sku);v.setInboundAt(at);v.setCreatedAt(at);v.setUpdatedAt(at);return v;} + /** 新分箱份额只引用原批次,不复制原托计费基数。 */ + private static StorageRentShare newShare(StorageRentLot lot,String mark,String sku,BigDecimal quantity){var s=new StorageRentShare();s.setId(IdWorker.getId());s.setLotId(lot.getId());s.setCustomerId(lot.getCustomerId());s.setWarehouseId(lot.getWarehouseId());s.setCartonMark(mark);s.setSkuCode(sku);s.setQuantity(quantity);return s;} + /** 普通实收数量与原批次建立可搬移的库存份额。 */ + private void insertStockShare(StorageRentLot lot,long balance,String batch,BigDecimal quantity){var s=newShare(lot,null,lot.getSkuCode(),quantity);s.setBalanceId(balance);s.setBatchNo(batch);repository.insert(s);} + /** 业务状态冲突沿现有 API 错误协议返回。 */ + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} + /** 资源不存在。 */ + private static ResponseStatusException missing(String message){return new ResponseStatusException(HttpStatus.NOT_FOUND,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java new file mode 100644 index 000000000..6bdff18e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java @@ -0,0 +1,170 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.baiyue.lastleg.entity.LastlegOrder; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.math.*; +import java.time.LocalDateTime; +import java.util.*; + +/** 与真实 PDA 原库存位置、原退货箱及当前服务业务单同源解析。 */ +@Service +public class StorageRentSourceResolver { + @Resource private StorageRentRepository repository; + @Resource private com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway pricingGateway; + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository discards; + /** 客户、仓库及费用单号均从持久化来源取值,绝不信任前端客户字段。 */ + public Plan resolve(String warehouse,PrepareRequest request,LocalDateTime at) { + var plan=resolveSources(warehouse,request,at); + if(plan.allocations().isEmpty())return plan; + return reprice(plan,pricingGateway.current(plan.owner().customerId())); + } + /** 原预占消费只使用已保存的客户价格;仍重新校验实际库存和业务归属。 */ + public Plan resolve(String warehouse,PrepareRequest request,LocalDateTime at,Pricing snapshot) { + var plan=resolveSources(warehouse,request,at); + if(plan.allocations().isEmpty())return plan; + com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway.validate(snapshot,Long.parseLong(plan.owner().customerId())); + return reprice(plan,snapshot); + } + /** 定价与来源解析分离,原批次体积、原托份额及实收时间始终保持不变。 */ + private Plan reprice(Plan plan,Pricing pricing) { + var allocations=plan.allocations().stream().map(a->{var lot=repository.lockLot(a.lotId()); + return new Allocation(a.lotId(),a.shareId(),a.quantity(),StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()),a.inboundAt(),a.days());}).toList(); + return new Plan(plan.owner(),allocations,plan.calculatedAt(),allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP),pricing); + } + /** 先解析真实来源;此阶段默认金额只用于内部数量校验,不作为最终收费依据。 */ + private Plan resolveSources(String warehouse,PrepareRequest request,LocalDateTime at) { + if("DISCARD".equals(request.mode()))return discard(warehouse,request,at); + if(request.itemId()==null||request.packages()==null||request.packages()<=0)throw conflict("扫码来源明细与数量不能为空"); + Owner owner;String customer;String marks=null;String sku=null;Long pieceQuantity=null;Long palletId=null; + Long expectedOrderId=null;String expectedInboundCode=null;Long expectedPositionId=null; + if("DISPATCH".equals(request.mode())){ + var dispatch=repository.dispatch(request.businessNo());var item=repository.dispatchItem(request.itemId()); + if(dispatch==null||item==null||!Objects.equals(dispatch.getId(),item.getDispatchOrderId()) + ||!warehouse.equals(dispatch.getOverseasWarehouseId())||!warehouse.equals(item.getOverseasWarehouseId()))throw conflict("扫码来源不属于当前仓排车单"); + if(!"ACTIVE".equals(dispatch.getStatus())||!"LOADING".equals(dispatch.getStage()))throw conflict("当前排车阶段不允许预占仓租"); + customer=item.getCustomerId();palletId=item.getWarehousePalletId(); + expectedOrderId=item.getOrderId();expectedInboundCode=item.getInboundCode(); + if("RETURN_RELABEL".equals(item.getSourceStage())){marks=request.cartonMark();if(marks==null)throw conflict("退货换标必须扫描原箱唛");} + owner=business(customer,item.getCustomerName(),item.getSourceBusinessType(),item.getSourceBusinessNo(),item.getOrderId(),warehouse,item.getInboundCode()); + }else if("OUTBOUND".equals(request.mode())){ + var task=repository.task(warehouse,request.businessNo());var item=repository.outboundItem(request.itemId()); + if(task==null||item==null||!Objects.equals(task.getOutboundOrderId(),item.getOutboundOrderId())||!task.getCustomerId().equals(item.getCustomerId()))throw conflict("扫码来源不属于当前仓出库工单"); + var outbound=repository.outbound(task.getOutboundOrderId()); + if(outbound==null||!warehouse.equals(outbound.getOverseasWarehouseId())||!Set.of("PENDING","IN_PROGRESS").contains(task.getStatus()))throw conflict("当前出库工单不能预占仓租"); + customer=task.getCustomerId();palletId=item.getWarehousePalletId(); + expectedOrderId=item.getSourceOrderId();expectedInboundCode=item.getInboundCode();expectedPositionId=item.getSourceInventoryPositionId(); + if("RETURN_OUTBOUND".equals(outbound.getOutboundType())||"RELABEL_OUTBOUND".equals(outbound.getOutboundType())&&palletId==null){ + marks=item.getCartonMarks();sku=item.getSkuCode();pieceQuantity=item.getReturnSkuQuantity(); + // SKU 扫码的商品数量必须与出库单不可变快照一致,不能用商品条码代替任意箱。 + if(pieceQuantity!=null&&(!Objects.equals(sku,request.skuCode())||!Objects.equals(pieceQuantity,request.productQuantity())))throw conflict("扫码商品及数量与原出库单不一致"); + if(pieceQuantity==null&&(request.cartonMarks()==null||!request.cartonMarks().contains(marks)))throw conflict("扫码箱唛与原出库单不一致"); + } + if("RELABEL_OUTBOUND".equals(outbound.getOutboundType())){ + var relabel=repository.relabel(customer,outbound.getOutboundNo());if(relabel==null)throw conflict("出库单缺少对应换标申请"); + owner=new Owner(customer,null,"RELABEL_ORDER",relabel.getId(),relabel.getBusinessNo(),relabel.getLastlegOrderId(),item.getInboundCode(),warehouse); + }else if("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())){ + var transfer=repository.transfer(customer,item.getOrderRefs()); + if(transfer==null&&item.getSourceOrderId()!=null)transfer=repository.transferByTail(customer,item.getSourceOrderId()); + if(transfer==null)throw conflict("出库单缺少对应仓储转运业务单"); + owner=new Owner(customer,null,"WAREHOUSE_TRANSFER",transfer.getId(),transfer.getBusinessNo(),transfer.getLastlegOrderId(),item.getInboundCode(),warehouse); + }else if("RETURN_OUTBOUND".equals(outbound.getOutboundType())){ + var order=repository.returnOrder(customer,item.getInboundCode());if(order==null)throw conflict("出库单缺少对应退货业务单"); + owner=new Owner(customer,null,"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,warehouse); + }else owner=business(customer,null,"LASTLEG_ORDER",null,item.getSourceOrderId(),warehouse,item.getInboundCode()); + }else throw conflict("整托预校验应先展开为真实扫码来源"); + List allocations; + if(marks!=null){if(request.positionId()!=null)throw conflict("退货扫码不能指定普通库存位置");allocations=returnAllocations(customer,warehouse,marks,sku,pieceQuantity,at);} + else { + var position=repository.position(request.positionId()); + if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId()) + ||palletId!=null&&!palletId.equals(position.getPalletId()))throw conflict("仓租预校验库存位置不属于原客户和原托盘"); + var balance=repository.balance(position.getBalanceId());if(balance==null||!customer.equals(balance.getCustomerId()))throw conflict("仓租来源库存余额不存在"); + if(expectedOrderId!=null&&!Objects.equals(expectedOrderId,balance.getInboundOrderId()))throw conflict("扫码库存不属于业务单据关联的入库订单"); + if(expectedInboundCode!=null&&!expectedInboundCode.isBlank()&&!Objects.equals(expectedInboundCode,balance.getInboundCode()))throw conflict("扫码库存不属于业务单据关联的入仓码"); + if(expectedPositionId!=null&&!Objects.equals(expectedPositionId,position.getId()))throw conflict("扫码库存位置与出库单来源位置不一致"); + var original=repository.order(balance.getInboundOrderId()); + var shares=repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo()); + if(shares.isEmpty()){ + if(original==null)throw conflict("原库存缺少有效业务订单,不能判定计租范围"); + if(!StorageRentService.eligible(original))return new Plan(owner,List.of(),at,BigDecimal.ZERO.setScale(2)); + throw conflict("原实收批次计租数量不完整,不能出库"); + } + var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add); + if(total.compareTo(BigDecimal.valueOf(request.packages()))<0)throw conflict("原实收批次计租数量不足,不能出库"); + allocations=proportionShares(shares,BigDecimal.valueOf(request.packages()),total,at); + if(owner.businessId()==null||owner.businessNo()==null)owner=business(customer,balance.getCustomerName(),"LASTLEG_ORDER",null,balance.getInboundOrderId(),warehouse,balance.getInboundCode()); + } + return new Plan(owner,allocations,at,allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP)); + } + /** 废弃同样属于实物出仓,按原申请客户、仓位和退货原托份额结算。 */ + private Plan discard(String warehouse,PrepareRequest request,LocalDateTime at) { + var application=discards.lockApplication(request.businessNo(),warehouse); + if(application==null||!"APPROVED".equals(application.getStatus()))throw conflict("废弃申请未审核或不属于当前仓库"); + if(request.lines()==null||request.lines().isEmpty()||request.palletNo()==null)throw conflict("废弃托盘与实际明细不能为空"); + String customer=application.getCustomerId();var result=new ArrayList();var seen=new HashSet(); + for(var line:request.lines()){ + if(!seen.add(line.itemId()))throw conflict("废弃明细重复"); + var item=discards.item(line.itemId()); + if(item==null||!customer.equals(item.getCustomerId())||!application.getId().equals(item.getApplicationId())||line.actualPackages()<0||line.actualPackages()>item.getDiscardPackages())throw conflict("废弃数量或来源与申请不一致"); + String pallet;Long positionId=item.getInventoryPositionId(); + if(item.getReturnCartonId()!=null){var carton=discards.returnCarton(item.getReturnCartonId()); + if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("退货废弃来源不属于当前客户和仓库"); + pallet=carton.getPalletCode(); + if(line.actualPackages()>0)result.addAll(returnAllocations(customer,warehouse,carton.getCartonMark(),item.getSkuCode(),(long)line.actualPackages(),at)); + }else{ + if(positionId==null){var profile=discards.profile(item.getProfileId());if(profile==null||!customer.equals(profile.getCustomerId()))throw conflict("废弃原托盘不存在");positionId=profile.getInventoryPositionId();} + var position=repository.position(positionId); + if(position==null||!customer.equals(position.getCustomerId())||!warehouse.equals(position.getOverseasWarehouseId()))throw conflict("废弃原库存不属于当前仓库"); + pallet=position.getPalletNo();var shares=repository.stockShares(customer,warehouse,position.getBalanceId(),position.getBatchNo()); + if(shares.isEmpty()){ + var balance=repository.balance(position.getBalanceId());var order=balance==null?null:repository.order(balance.getInboundOrderId()); + if(order==null||StorageRentService.eligible(order))throw conflict("废弃原实收批次缺少计租记录"); + }else if(line.actualPackages()>0){var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(BigDecimal.valueOf(line.actualPackages()))<0)throw conflict("废弃原计租批次数量不足");result.addAll(proportionShares(shares,BigDecimal.valueOf(line.actualPackages()),total,at));} + } + if(!Objects.equals(pallet==null||pallet.isBlank()?"散箱":pallet.trim(),request.palletNo().trim()))throw conflict("废弃明细不属于本次托盘"); + } + var owner=new Owner(customer,null,"DISCARD_ORDER",application.getId(),application.getDiscardNo(),null,null,warehouse); + return new Plan(owner,result,at,result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP)); + } + /** 当前独立服务工单优先,不能把新换标或转运应收记回原头程运输单。 */ + private Owner business(String customer,String name,String type,String no,Long orderId,String warehouse,String inbound) { + if("RELABEL_ORDER".equals(type)){var value=repository.relabelNo(customer,no);if(value==null)throw conflict("排车来源换标业务单不存在");return new Owner(customer,name,type,value.getId(),value.getBusinessNo(),value.getLastlegOrderId(),inbound,warehouse);} + if("WAREHOUSE_TRANSFER".equals(type)){var value=repository.transfer(customer,no);if(value==null)throw conflict("排车来源转运业务单不存在");return new Owner(customer,name,type,value.getId(),value.getBusinessNo(),value.getLastlegOrderId(),inbound,warehouse);} + if("RETURN_ORDER".equals(type)){var value=repository.returnOrder(customer,no);if(value==null)throw conflict("排车来源退货业务单不存在");return new Owner(customer,name,type,value.getId(),value.getReturnNo(),value.getLastlegOrderId(),inbound,warehouse);} + LastlegOrder order=repository.order(orderId); + if(order==null)return new Owner(customer,name,"LASTLEG_ORDER",null,null,orderId,inbound,warehouse); + if(!customer.equals(order.getCustomerId()))throw conflict("原订单不属于扫码客户"); + if("WAREHOUSE_TRANSFER".equals(order.getOrderType())){var value=repository.transferByTail(customer,order.getId());if(value==null)throw conflict("原转运订单缺少独立业务工单");return new Owner(customer,name,"WAREHOUSE_TRANSFER",value.getId(),value.getBusinessNo(),order.getId(),inbound,warehouse);} + return new Owner(customer,name==null?order.getCustomerName():name,"LASTLEG_ORDER",order.getId(),order.getOrderNo(),order.getId(),inbound,warehouse); + } + /** 原批次内部混入多次实收时按剩余来源份额分配,不能跨来源用 FIFO 猜箱。 */ + private List proportionShares(List shares,BigDecimal quantity,BigDecimal total,LocalDateTime at) { + var result=new ArrayList();BigDecimal used=BigDecimal.ZERO; + for(int i=0;i0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));} + return result; + } + /** 退货按当前箱内 SKU 的原托份额计费,部分 SKU 出库不会新增一个计费整托。 */ + private List returnAllocations(String customer,String warehouse,String mark,String sku,Long quantity,LocalDateTime at) { + var carton=repository.carton(mark);if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("原退货箱不属于当前客户和仓库"); + var shares=repository.shares(customer,warehouse,mark,sku);var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal requested=quantity==null?total:BigDecimal.valueOf(quantity); + if(shares.isEmpty()||requested.signum()<=0||total.compareTo(requested)<0)throw conflict("退货原托计租份额不完整,不能出库"); + var result=new ArrayList();BigDecimal used=BigDecimal.ZERO; + for(int i=0;i0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));} + return result; + } + /** 单份金额保留十二位,仅在完整扫码汇总时按分取整。 */ + private static Allocation allocation(StorageRentLot lot,Long share,BigDecimal quantity,LocalDateTime at) { + if(lot==null)throw conflict("原仓租批次不存在"); + try{return new Allocation(lot.getId(),share,quantity,StorageRentCalculator.amount(lot.getRuleCode(),lot.getInboundAt(),at,lot.getOriginalUnits(),lot.getOriginalQuantity(),quantity),lot.getInboundAt(),StorageRentCalculator.days(lot.getInboundAt(),at));} + catch(IllegalArgumentException e){throw conflict(e.getMessage());} + } + /** 来源不一致不得降级为免租。 */ + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationService.java new file mode 100644 index 000000000..14a715b38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationService.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.storagerent.service.preparation; + +import com.baiyue.lastleg.storagerent.service.StorageRentService; + +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.*; + +/** 整托和普通扫码共用实际出库规划,避免资金预占与库存扣减来源不同。 */ +@Service +public class StorageRentPreparationService { + @Resource private StorageRentService rents; + @Resource private DeliveryTransferService delivery; + /** 整托每条原扫码分别冻结,只有全部成功才允许调用原整托接口。 */ + @Transactional + public PreparationResult prepare(PdaWarehouseIdentity identity,PrepareRequest request) { + String warehouse=String.valueOf(identity.overseasWarehouseId()); + if(!"OUTBOUND_PALLET".equals(request.mode()))return rents.prepare(warehouse,request); + var scans=delivery.storageRentPalletRequests(warehouse,identity.operatorKey(),request.businessNo(),request.palletNo(),new DeliveryRequests.PdaPalletPick(request.idempotencyKey())); + var results=new ArrayList(); + for(var scan:scans)results.add(rents.prepare(warehouse,new PrepareRequest("OUTBOUND",request.businessNo(),scan.outboundPalletItemId(),scan.inventoryPositionId(),scan.packages(),null,null,scan.idempotencyKey(),scan.cartonMarks(),scan.skuCode(),scan.productQuantity()))); + var blocker=results.stream().filter(r->!r.ready()).findFirst().orElse(null); + var currencies=results.stream().filter(r->!"NOT_REQUIRED".equals(r.status())).map(PreparationResult::originalCurrency).distinct().toList(); + boolean single=currencies.size()==1; + var priced=single?results.stream().filter(r->r.exchangeRate()!=null).findFirst().orElse(null):null; + boolean sameRate=priced!=null&&results.stream().filter(r->r.exchangeRate()!=null).allMatch(r->r.exchangeRate().compareTo(priced.exchangeRate())==0&&Objects.equals(r.exchangeRateMonth(),priced.exchangeRateMonth())); + return new PreparationResult(null,blocker==null?"READY":blocker.status(),blocker==null, + results.stream().map(PreparationResult::amount).reduce(BigDecimal.ZERO,BigDecimal::add),"CNY", + single?results.stream().map(PreparationResult::originalAmount).reduce(BigDecimal.ZERO,BigDecimal::add):currencies.isEmpty()?BigDecimal.ZERO:null,single?currencies.get(0):null, + sameRate?priced.exchangeRate():null,sameRate?priced.exchangeRateMonth():null,blocker==null?null:blocker.message()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java index 013d8b638..02b69112d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java @@ -22,6 +22,8 @@ import java.util.*; /** Runs in customer confirmation's transaction; preserves original selections and follows actual pallet moves. */ @Service public class WarehouseTransferRelabelCompletionService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + @Resource private WarehouseTransferInventoryAllocationMapper allocations; @Resource private WarehouseTransferRelabelBindingMapper bindings; @Resource private InventoryCommandMapper inventoryRows; @@ -86,7 +88,11 @@ public class WarehouseTransferRelabelCompletionService { balance.getProductNameZh(), balance.getProductNameEn(), current.getPalletId(), current.getPalletNo(), key + ":OUTPUT", current.getWarehouseLocationId(), current.getWarehouseLocationCode(), count, transfer.getTransferNo(), key, key + ":TARGET", "本票换标新SKU转入")); - current = inventoryRows.lockPosition(customer, output.positionId()); + // 变更 SKU 后搬移原计租份额,原入库时间和免费期不变。 + var rentTarget = inventoryRows.lockPosition(customer, output.positionId()); + storageRent.moveStockShares(customer, balance.getOverseasWarehouseId(), balance.getId(), current.getBatchNo(), + output.balanceId(), rentTarget.getBatchNo(), count); + current = rentTarget; balance = inventoryRows.lockBalance(customer, output.balanceId()); if (current == null || balance == null || !Objects.equals(current.getBalanceId(), balance.getId()) || !Objects.equals(balance.getInboundOrderId(), allocation.getInboundOrderId())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 899052ff2..f7724b2cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -92,6 +92,8 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { + @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; + @Resource private TransferQuantityService transferQuantityService; @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; @Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTaskService; @@ -1344,6 +1346,11 @@ public class WarehouseTransferService { receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt); + // 外来货进入自有海外仓后立即计租;按该实托实际体积分到原 SKU 箱数。 + storageRent.registerTransfer(receipt, transfer, + pallet.getVolumeCbm()!=null&&pallet.getVolumeCbm().signum()>0&&pallet.getCartonCount()!=null&&pallet.getCartonCount()>0 + ?pallet.getVolumeCbm().multiply(java.math.BigDecimal.valueOf(row.getPackageCount())) + .divide(java.math.BigDecimal.valueOf(pallet.getCartonCount()), 12, java.math.RoundingMode.HALF_UP):null, warehouse.warehouseName()); } } } @@ -1543,6 +1550,7 @@ public class WarehouseTransferService { LocalDateTime now = LocalDateTime.now(); for (WarehouseTransferInventoryAllocation allocation : allocations) { if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed"); + storageRent.requirePdaOutbound(customerId,transfer.getOperatingWarehouseId(),allocation.getInventoryPositionId()); String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId(); InventoryCommandRequests.Move move = new InventoryCommandRequests.Move( allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index f5e6e42a4..8c8b975ea 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -880,7 +880,8 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID', container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照', warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照', - fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', fee_remark VARCHAR(500) NULL COMMENT '费用说明,补收或重复作业原因', + direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照', supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', @@ -888,19 +889,20 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( wallet_settlement JSON NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用', business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD', - original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1', + original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NULL COMMENT '原币折算人民币汇率,人民币固定为1,自动零元仓租可空', exchange_rate_month CHAR(7) NOT NULL COMMENT '汇率月份yyyy-MM', chargeable_quantity DECIMAL(20,6) NULL COMMENT '计费数量,如箱数、托数、体积或天数', quantity_unit VARCHAR(32) NULL COMMENT '计费数量单位', unit_price DECIMAL(20,6) NULL COMMENT '原币计费单价', period_start DATE NULL COMMENT '仓租等期间费用开始日期', period_end DATE NULL COMMENT '仓租等期间费用结束日期', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', - review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照', reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at), - CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), + CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')), + CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), CONSTRAINT chk_ll_finance_fee_original_currency CHECK(original_currency IN('CNY','USD')), CONSTRAINT chk_ll_finance_fee_period CHECK((period_start IS NULL AND period_end IS NULL) OR (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start<=period_end)), CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), @@ -2926,6 +2928,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( ) ENGINE=InnoDB COMMENT='货物废弃审批历史'; CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + procurement_direction VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS_TO_DOMESTIC' COMMENT '采购方向:海外采购回国内/国内采购发海外', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', @@ -3261,7 +3264,7 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost ( ) ENGINE=InnoDB COMMENT='拆柜打托实际成本汇总'; CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( id BIGINT NOT NULL COMMENT '主键ID', cost_id BIGINT NOT NULL COMMENT '打托成本汇总ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收体积', - allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NOT NULL COMMENT '海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NULL COMMENT '人工审核后关联的海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; @@ -3923,6 +3926,7 @@ CREATE TABLE IF NOT EXISTS ll_return_sku_conversion ( quantity BIGINT NOT NULL COMMENT '实际完成转换件数', status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回', ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回', + storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照', completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间', confirmed_at DATETIME(3) NULL COMMENT '客户确认时间', reversed_at DATETIME(3) NULL COMMENT '退回冲回时间', @@ -4658,3 +4662,126 @@ CREATE TABLE IF NOT EXISTS ll_document_sequence ( PRIMARY KEY (sequence_key), CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; + +-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。 +CREATE TABLE IF NOT EXISTS ll_finance_fee_funding ( + source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID', + customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识', + collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信', + wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态', + wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号', + command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID', + command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账', + error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空', + command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执', + created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间', + PRIMARY KEY (source_fee_id), + KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status), + KEY idx_ll_fee_funding_command (command_event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排'; + +-- 实收仓租原批次、份额与资金预占。 +CREATE TABLE IF NOT EXISTS ll_storage_rent_inbound ( + receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量', + lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键', + quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数', + created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时', + PRIMARY KEY (receipt_id), + KEY idx_storage_rent_inbound_lot (lot_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记'; + +CREATE TABLE IF NOT EXISTS ll_storage_rent_lot ( + id bigint NOT NULL COMMENT '原批次主键', + source_key varchar(80) NOT NULL COMMENT '实收来源幂等键', + customer_id varchar(32) NOT NULL COMMENT '客户标识', + customer_name varchar(255) DEFAULT NULL COMMENT '客户名称', + warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识', + warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称', + business_type varchar(32) NOT NULL COMMENT '原入库业务类型', + business_id bigint NOT NULL COMMENT '原入库业务主键', + business_no varchar(128) NOT NULL COMMENT '原入库业务单号', + order_id bigint DEFAULT NULL COMMENT '尾程订单主键', + inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码', + balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键', + batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次', + sku_code varchar(128) DEFAULT NULL COMMENT '商品编码', + rule_code varchar(32) NOT NULL COMMENT '仓租规则', + original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量', + remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量', + original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数', + inbound_at datetime(3) NOT NULL COMMENT '实际入库时间', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_lot_source (source_key), + KEY idx_storage_rent_stock (balance_id,batch_no), + KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次'; + +CREATE TABLE IF NOT EXISTS ll_storage_rent_share ( + balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空', + batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用', + id bigint NOT NULL COMMENT '份额主键', + lot_id bigint NOT NULL COMMENT '原计租批次主键', + customer_id varchar(32) NOT NULL COMMENT '客户标识', + warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识', + carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛', + sku_code varchar(128) DEFAULT NULL COMMENT '商品编码', + quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code), + UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no), + KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark), + KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额'; + +CREATE TABLE IF NOT EXISTS ll_storage_rent_preparation ( + id bigint NOT NULL COMMENT '预校验主键', + warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓', + customer_id varchar(32) NOT NULL COMMENT '费用客户', + mode varchar(32) NOT NULL COMMENT '扫码类型', + execution_no varchar(128) NOT NULL COMMENT '实际作业单号', + request_key varchar(255) NOT NULL COMMENT '原扫码幂等键', + request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希', + attempt int NOT NULL COMMENT '冻结尝试次数', + business_type varchar(32) NOT NULL COMMENT '本次应收业务类型', + business_id bigint NOT NULL COMMENT '本次应收业务主键', + business_no varchar(128) NOT NULL COMMENT '本次应收业务单号', + status varchar(32) NOT NULL COMMENT '资金与扫码状态', + plan_json json NOT NULL COMMENT '原批次计费不可变快照', + original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照', + amount decimal(24,12) NOT NULL COMMENT '人民币结算金额', + exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空', + exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份', + freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件', + settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件', + release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件', + release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发', + wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计', + hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键', + ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水', + error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果', + source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键', + created_at datetime(3) NOT NULL COMMENT '预校验时间', + consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间', + updated_at datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt), + KEY idx_storage_rent_execution (mode,execution_no,status), + KEY idx_storage_rent_business (customer_id,business_no), + KEY idx_storage_rent_recovery (status,updated_at), + KEY idx_storage_rent_release (release_status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照'; + +-- SKU 换标原计租份额快照,存量数据库可重复执行。 +SET @storage_rent_sku_column = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_sku_conversion' AND column_name='storage_rent_shares_json'); +SET @storage_rent_sku_ddl = IF(@storage_rent_sku_column=0, 'ALTER TABLE ll_return_sku_conversion ADD COLUMN storage_rent_shares_json LONGTEXT NULL COMMENT ''原仓租批次迁移快照'' AFTER ownership_json', 'SELECT 1'); +PREPARE storage_rent_sku_stmt FROM @storage_rent_sku_ddl; +EXECUTE storage_rent_sku_stmt; +DEALLOCATE PREPARE storage_rent_sku_stmt; + +-- 首版拆柜成本只记录分摊依据,客户应收另行人工登记审核;重复执行保持相同定义。 +ALTER TABLE ll_destuff_pallet_cost_allocation MODIFY COLUMN fee_source_id BIGINT NULL COMMENT '人工审核后关联的海外费用来源ID'; diff --git a/lastleg-service/src/main/resources/db/overseas-manual-fee-funding.sql b/lastleg-service/src/main/resources/db/overseas-manual-fee-funding.sql new file mode 100644 index 000000000..4f2fe2837 --- /dev/null +++ b/lastleg-service/src/main/resources/db/overseas-manual-fee-funding.sql @@ -0,0 +1,22 @@ +-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。 +CREATE TABLE IF NOT EXISTS ll_finance_fee_funding ( + source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID', + customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识', + collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信', + wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态', + wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号', + command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID', + command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账', + error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空', + command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执', + created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间', + PRIMARY KEY (source_fee_id), + KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status), + KEY idx_ll_fee_funding_command (command_event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排'; + +-- 首版拆柜成本只记录分摊依据,客户应收另行人工登记审核;重复执行保持相同定义。 +ALTER TABLE ll_destuff_pallet_cost_allocation MODIFY COLUMN fee_source_id BIGINT NULL COMMENT '人工审核后关联的海外费用来源ID'; \ No newline at end of file diff --git a/lastleg-service/src/main/resources/db/return-sku-conversion.sql b/lastleg-service/src/main/resources/db/return-sku-conversion.sql index 0a22307ed..c976dd373 100644 --- a/lastleg-service/src/main/resources/db/return-sku-conversion.sql +++ b/lastleg-service/src/main/resources/db/return-sku-conversion.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS ll_return_sku_conversion ( quantity BIGINT NOT NULL COMMENT '实际完成转换件数', status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回', ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回', + storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照', completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间', confirmed_at DATETIME(3) NULL COMMENT '客户确认时间', reversed_at DATETIME(3) NULL COMMENT '退回冲回时间', diff --git a/lastleg-service/src/main/resources/db/storage-rent.sql b/lastleg-service/src/main/resources/db/storage-rent.sql new file mode 100644 index 000000000..2172d7780 --- /dev/null +++ b/lastleg-service/src/main/resources/db/storage-rent.sql @@ -0,0 +1,98 @@ +CREATE TABLE IF NOT EXISTS ll_storage_rent_inbound ( + receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量', + lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键', + quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数', + created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时', + PRIMARY KEY (receipt_id), + KEY idx_storage_rent_inbound_lot (lot_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记'; + +CREATE TABLE IF NOT EXISTS ll_storage_rent_lot ( + id bigint NOT NULL COMMENT '原批次主键', + source_key varchar(80) NOT NULL COMMENT '实收来源幂等键', + customer_id varchar(32) NOT NULL COMMENT '客户标识', + customer_name varchar(255) DEFAULT NULL COMMENT '客户名称', + warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识', + warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称', + business_type varchar(32) NOT NULL COMMENT '原入库业务类型', + business_id bigint NOT NULL COMMENT '原入库业务主键', + business_no varchar(128) NOT NULL COMMENT '原入库业务单号', + order_id bigint DEFAULT NULL COMMENT '尾程订单主键', + inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码', + balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键', + batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次', + sku_code varchar(128) DEFAULT NULL COMMENT '商品编码', + rule_code varchar(32) NOT NULL COMMENT '仓租规则', + original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量', + remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量', + original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数', + inbound_at datetime(3) NOT NULL COMMENT '实际入库时间', + created_at datetime(3) NOT NULL COMMENT '创建时间', + updated_at datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_lot_source (source_key), + KEY idx_storage_rent_stock (balance_id,batch_no), + KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次'; + +CREATE TABLE IF NOT EXISTS ll_storage_rent_share ( + balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空', + batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用', + id bigint NOT NULL COMMENT '份额主键', + lot_id bigint NOT NULL COMMENT '原计租批次主键', + customer_id varchar(32) NOT NULL COMMENT '客户标识', + warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识', + carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛', + sku_code varchar(128) DEFAULT NULL COMMENT '商品编码', + quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code), + UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no), + KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark), + KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额'; + +CREATE TABLE IF NOT EXISTS ll_storage_rent_preparation ( + id bigint NOT NULL COMMENT '预校验主键', + warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓', + customer_id varchar(32) NOT NULL COMMENT '费用客户', + mode varchar(32) NOT NULL COMMENT '扫码类型', + execution_no varchar(128) NOT NULL COMMENT '实际作业单号', + request_key varchar(255) NOT NULL COMMENT '原扫码幂等键', + request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希', + attempt int NOT NULL COMMENT '冻结尝试次数', + business_type varchar(32) NOT NULL COMMENT '本次应收业务类型', + business_id bigint NOT NULL COMMENT '本次应收业务主键', + business_no varchar(128) NOT NULL COMMENT '本次应收业务单号', + status varchar(32) NOT NULL COMMENT '资金与扫码状态', + plan_json json NOT NULL COMMENT '原批次计费不可变快照', + original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照', + amount decimal(24,12) NOT NULL COMMENT '人民币结算金额', + exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空', + exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份', + freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件', + settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件', + release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件', + release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发', + wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计', + hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键', + ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水', + error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果', + source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键', + created_at datetime(3) NOT NULL COMMENT '预校验时间', + consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间', + updated_at datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt), + KEY idx_storage_rent_execution (mode,execution_no,status), + KEY idx_storage_rent_business (customer_id,business_no), + KEY idx_storage_rent_recovery (status,updated_at), + KEY idx_storage_rent_release (release_status,updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照'; + +-- SKU 换标原计租份额快照,存量数据库可重复执行。 +SET @storage_rent_sku_column = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_sku_conversion' AND column_name='storage_rent_shares_json'); +SET @storage_rent_sku_ddl = IF(@storage_rent_sku_column=0, 'ALTER TABLE ll_return_sku_conversion ADD COLUMN storage_rent_shares_json LONGTEXT NULL COMMENT ''原仓租批次迁移快照'' AFTER ownership_json', 'SELECT 1'); +PREPARE storage_rent_sku_stmt FROM @storage_rent_sku_ddl; +EXECUTE storage_rent_sku_stmt; +DEALLOCATE PREPARE storage_rent_sku_stmt; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 54830a9ff..e23c85b8b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -2378,8 +2378,10 @@ paths: content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } } /api/platform/v1/last-leg/overseas-procurements/warehouse-options: get: - summary: 查询有效公共海外收货仓候选 - description: 权限 platform:overseas-business:purchase:list;固定国内权威公共 OVERSEAS、ACTIVE、RECEIVING 仓,浏览器不访问内部 API。 + summary: 按采购方向查询有效公共起运收货仓候选 + description: 权限 platform:overseas-business:purchase:list;海外采购取 OVERSEAS、ACTIVE、RECEIVING 公共仓,国内采购取 DOMESTIC、ACTIVE 公共仓,均以 Warehouse 为准。 + parameters: + - { name: procurementDirection, in: query, schema: { $ref: '#/components/schemas/ProcurementDirection' } } security: [{ bearerAuth: [] }] responses: '200': @@ -3705,6 +3707,7 @@ components: properties: feeItemCode: { type: string, minLength: 1, maxLength: 64 } feeItemName: { type: string, minLength: 1, maxLength: 128 } + feeRemark: { type: string, maxLength: 500, nullable: true, description: 费用说明;同单同方向已有相同项目时须填写补收或重复作业原因 } direction: { type: string, enum: [RECEIVABLE, PAYABLE] } currency: { type: string, enum: [CNY, USD] } amount: { type: number, format: decimal, minimum: 0.01 } @@ -3712,7 +3715,7 @@ components: supplierId: { type: string, pattern: '^[1-9][0-9]*$' } supplierName: { type: string, maxLength: 200 } supplierCategory: { type: string, maxLength: 32 } - settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] } + settlementMode: { type: string, enum: [BILL, WALLET, ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 应收可选推送结算或余额扣费;应付使用账期或已付 } chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true } quantityUnit: { type: string, maxLength: 32 } unitPrice: { type: number, minimum: 0, exclusiveMinimum: true } @@ -3729,6 +3732,7 @@ components: orderId: { type: string, pattern: '^[1-9][0-9]*$' } feeItemCode: { type: string } feeItemName: { type: string } + feeRemark: { type: string, maxLength: 500, nullable: true, description: 业务费用说明,与审核原因分开保存 } direction: { type: string, enum: [RECEIVABLE, PAYABLE] } currency: { type: string, enum: [CNY] } amount: { type: number, format: decimal } @@ -4201,7 +4205,7 @@ components: supplierId: { type: string, pattern: '^[1-9][0-9]*$' } supplierName: { type: string, maxLength: 200 } supplierCategory: { type: string, maxLength: 32 } - settlementMode: { type: string, enum: [ON_ACCOUNT, DIRECT_PAID] } + settlementMode: { type: string, enum: [BILL, WALLET, ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 应收可选推送结算或余额扣费;应付使用账期或已付 } chargeableQuantity: { type: number, minimum: 0, exclusiveMinimum: true } quantityUnit: { type: string, maxLength: 32 } unitPrice: { type: number, minimum: 0, exclusiveMinimum: true } @@ -4507,7 +4511,7 @@ components: labelDeadline: { type: string, format: date-time } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } - chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 } + chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 换标报价快照(CNY);不自动冻结或收费,应收由海外费用登记审核 } remark: { type: string, nullable: true, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传;仅TRUCK进入平台预排车,其余方式直接创建出库任务 } @@ -4674,10 +4678,16 @@ components: - type: object required: [records] properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } } + ProcurementDirection: + type: string + enum: [OVERSEAS_TO_DOMESTIC, DOMESTIC_TO_OVERSEAS] + default: OVERSEAS_TO_DOMESTIC + description: 海外采购回国内或国内采购发海外;创建可选,待审核时可核定,通过后锁定。历史运输段编码保持不变,区域文案按方向展示。 ProcurementSummary: type: object required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, status, version, createdAt, updatedAt] properties: + procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' } id: { type: string, pattern: '^[0-9]+$' } orderNo: { type: string } customerId: { type: string } @@ -5041,12 +5051,13 @@ components: actualVolumeCbm: { type: number, nullable: true } status: { type: string } version: { type: integer } - ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] } + ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, WAIT_OVERSEAS_SHIPMENT, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, WAIT_CUSTOMER_SHIPMENT, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] } ProcurementReview: type: object required: [version, decision, idempotencyKey] - description: 审核通过必须选择有效公共海外收货仓;拒绝不要求仓库。 + description: 审核通过必须选择与采购方向匹配的有效公共收货仓;国内采购选择国内仓,海外采购选择海外收货仓;拒绝不要求仓库。已有购买或收货事实不得切换方向。 properties: + procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' } version: { type: integer, format: int64, minimum: 0 } decision: { type: string, enum: [APPROVE, REJECT] } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } @@ -5056,6 +5067,7 @@ components: type: object required: [productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] properties: + procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' } customerName: { type: string, nullable: true, maxLength: 255 } purchaseCategory: { type: string, maxLength: 128 } productName: { type: string, maxLength: 255 } @@ -5064,7 +5076,7 @@ components: purchaseQuantity: { type: integer, format: int64, minimum: 1 } budgetUnitPrice: { type: number, minimum: 0, multipleOf: 0.01, description: 客户授权的货款预算单价;零表示仅授权免费样品 } currency: { type: string, enum: [CNY, USD] } - receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户国内收件完整地址 } + receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户目的地完整地址;国内采购发海外时填写海外地址 } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } remark: { type: string, nullable: true, maxLength: 1000 } @@ -7683,6 +7695,7 @@ components: nullable: true DispatchFeeItem: type: object + description: 派送应付费用。客户应收费用统一到海外费用登记并审核,派送完成不自动生成应收。 x-java-record: DispatchDeliveryModels.FeeItem properties: feeType: @@ -7693,6 +7706,7 @@ components: nullable: true direction: type: string + enum: [PAYABLE] nullable: true currency: type: string diff --git a/lastleg-service/src/main/resources/openapi/overseas-fee-catalog-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-fee-catalog-v1.yaml new file mode 100644 index 000000000..1abb68dce --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-fee-catalog-v1.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.3 +info: { title: 海外录费公共费用项目, version: 1.0.0 } +security: [{ bearerAuth: [] }] +paths: + /api/platform/v1/last-leg/finance-fee-sources/fee-items: + get: + summary: 按收付方向选择当前启用的海外费用项目 + description: '仅平台主体;需要platform:overseas-fee:list或platform:finance:customer-arap:list。复用Firstleg目录,不建立独立目录。录费时重新校验当前编码和名称。' + parameters: + - name: direction + in: query + required: false + schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE } + responses: + '400': { description: 收付方向无效 } + '200': { description: '统一响应data为数组;每项code、name、defaultPrice。默认价仅CNY,USD录费不得直接套用。' } + '403': { description: 身份或查询权限不满足 } + '409': { description: 费用目录未就绪或响应无效 } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index 1257dc662..7f810acea 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -1,6 +1,13 @@ openapi: 3.0.3 -info: { title: Overseas Business Fee Source API, version: 1.0.0 } +info: { title: Overseas Business Fee Source API, version: 1.1.0, description: '首版除仓租外均人工登记审核;换标报价、外接运费及拆柜成本不自动收费;旧资金链同项目已锁款或收取时拒绝重复登记。' } paths: + /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/retries: + get: + summary: 查询该费用每次人工重试的时间、操作人和实际结果 + description: 与费用详情使用相同列表权限。按客户及稳定费用身份隔离,包含各版本的推送重试和资金重试。SUCCESS依据投递接收或实际资金成功回执;FAILED保留失败原因;SUBMITTED只代表请求已提交。多次重试分别保留,后一次成功不覆盖前一次失败。 + security: [{ bearerAuth: [] }] + parameters: [{ in: path, name: sourceFeeId, required: true, schema: { type: string } }] + responses: { '200': { description: 'data为RetryRecord数组,按重试时间倒序' } } /api/platform/v1/last-leg/finance-fee-sources: get: summary: 查询海外业务费用来源当前版本 @@ -12,12 +19,56 @@ paths: - { in: query, name: containerBookingId, description: 精确查询真实来源柜下全部客户的费用;可再传customerId收窄到单个客户, schema: { type: string, pattern: '^[1-9][0-9]*$' } } responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } } post: - summary: 创建海外业务费用来源草稿 - description: 采购费用仅由采购履约或财务节点实扣后自动发布,本入口拒绝采购来源或采购业务类型。客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 + summary: 创建人工费用并锁定资金 + description: 需platform:finance:fee-source:edit。创建即PENDING。应收仅当前LASTLEG_ORDER且orderType=FIRST_LEG采用BILL,其余业务(含ONE_CLICK_DROPSHIP、退货、转运、柜级费用)为WALLET,发送异步FREEZE,本土卡车、本土散板应收同采购一样使用客户余额及授信扣款;余额及授信不足显示FREEZE_FAILED,不可审核通过。原USD按业务月有效汇率固化为CNY后冻结;原币与汇率快照保留。采购来源仍只允许采购业务节点处理。业务ID、客户、单号与补充订单/入仓关联由服务端核对;真实柜要求businessId=containerBookingId且businessNo=containerNo。调用重放不重复冻结。 security: [{ bearerAuth: [] }] parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } - responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } } + responses: { '200': { description: 待审核费用,含collectionMode、walletStatus、allowedActions }, '400': { description: 业务归属或付款事实无效 } } + /api/platform/v1/last-leg/finance-fee-sources/page: + get: + summary: 分页查询海外费用 + description: 需platform:overseas-fee:list或platform:finance:customer-arap:list。只返回各稳定来源最新版本,按创建时间及ID倒序;应收是默认方向。资金状态批量加载。 + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: page, schema: { type: integer, minimum: 1, default: 1 } } + - { in: query, name: size, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { in: query, name: customerId, schema: { type: string } } + - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } + - { in: query, name: businessNo, schema: { type: string } } + - { in: query, name: feeItemCode, schema: { type: string } } + - { in: query, name: storageRentOnly, description: 只查询已实际出库生成的仓租费用,分页总数使用相同口径, schema: { type: boolean, default: false } } + - { in: query, name: reviewStatus, schema: { type: string, enum: [DRAFT, PENDING, APPROVED, REJECTED] } } + - { in: query, name: walletStatus, schema: { type: string } } + - { in: query, name: processingStatus, description: 业务费用状态,按当前版本事件及资金状态统一筛选并统计分页, schema: { type: string, enum: [WAITING, PROCESSING, COMPLETED, FAILED, CLOSED] } } + - { in: query, name: direction, schema: { type: string, enum: [RECEIVABLE, PAYABLE], default: RECEIVABLE } } + responses: + '200': + description: data为PageResult,包含records、page、size、total、pages + /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}: + get: + summary: 费用详情及服务端可执行动作 + security: [{ bearerAuth: [] }] + parameters: [{ in: path, name: sourceFeeId, required: true, schema: { type: string } }] + responses: { '200': { description: 当前费用及资金、审核、作废确认状态 }, '404': { description: 费用不存在 } } + /api/platform/v1/last-leg/finance-fee-sources/customer-options: + get: + summary: 查询已有海外业务或费用的客户候选 + security: [{ bearerAuth: [] }] + responses: { '200': { description: data为ContainerCustomerOption数组 } } + /api/platform/v1/last-leg/finance-fee-sources/business-options: + get: + summary: 查询真实服务业务单候选 + description: 只查Lastleg自有实体,每次最多50条;LASTLEG_ORDER保留真实orderType,区分FIRST_LEG与ONE_CLICK_DROPSHIP等业务。 + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: businessType, required: true, schema: { type: string, enum: [LASTLEG_ORDER, RELABEL_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER, US_TRUCK, LOCAL_LOOSEBOARD, DISCARD_ORDER, OVERSEAS_INBOUND, DELIVERY_ORDER] } } + - { in: query, name: customerId, schema: { type: string } } + - { in: query, name: keyword, schema: { type: string } } + responses: + '200': + description: 真实订单候选 + content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/BusinessOption' } } } } } } /api/platform/v1/last-leg/finance-fee-sources/container-customers: get: summary: 查询真实来源柜内客户选项 @@ -33,16 +84,132 @@ paths: schema: { $ref: '#/components/schemas/ContainerCustomerListResponse' } /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: post: - summary: 提交、审核、调整或作废海外业务费用来源 - description: 采购来源不接受通用提交、审核、调整或作废,须回到采购财务节点处理。审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。 + summary: 提交、审核、调整、作废或处理资金 + x-headleg-delivery-review: 首版头程派送费由审核员核对原报价是否已包含;当前未实现自动识别。已含费用不应再建单或通过审核,另收费用人工添加后审核进入头程账单。 + description: 编辑动作需platform:finance:fee-source:edit,review与void需platform:finance:fee-source:review。WALLET通过审核只发送SETTLE,实际匹配成功回执含冻结与账本引用后才PUBLISHED,此时仅本地费用完成、不推送财务账单;失败为SETTLE_FAILED,保持未收。驳回释放冻结,冻结在途时回执成功后自动释放。驳回后的费用只读,不能继续操作;如需重新收费应在业务单据中新增费用。头程BILL审核通过推送结算;已发布BILL作废等待Finance持久化确认才VOIDED。历史无资金记录的海外费用必须重新提交完成冻结,不允许审核绕过。采购来源仍由采购节点处理。 security: [{ bearerAuth: [] }] parameters: - { in: path, name: sourceFeeId, required: true, schema: { type: string } } - - { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void] } } + - { in: path, name: action, required: true, schema: { type: string, enum: [submit, review, adjust, void, retry-funding, retry-delivery] } } + requestBody: + required: true + content: + application/json: + schema: + description: 请求格式随路径action选择;submit、cancel、retry-funding用ActionRequest,其他动作使用同名请求。 + anyOf: + - { $ref: '#/components/schemas/ActionRequest' } + - { $ref: '#/components/schemas/ReviewRequest' } + - { $ref: '#/components/schemas/AdjustRequest' } + - { $ref: '#/components/schemas/VoidRequest' } + - { $ref: '#/components/schemas/RetryDeliveryRequest' } + examples: + review: { value: { decision: APPROVE, idempotencyKey: review-unique-key } } + funding: { value: { idempotencyKey: retry-unique-key } } responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } } components: schemas: - BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, PROCUREMENT_FEE, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] } + RetryRecord: + type: object + properties: + requestId: { type: string } + requestedAt: { type: string, format: date-time, description: 系统 UTC 时间,明确携带 Z 时区 } + operatorName: { type: string, description: 认证操作人名称及ID,非客户端提交 } + action: { type: string } + result: { type: string, enum: [SUBMITTED, SUCCESS, FAILED] } + errorMessage: { type: string, nullable: true } + RetryDeliveryRequest: + type: object + description: 需platform:finance:fee-source:edit。可重试当前费用对应的DEAD事件或已有失败原因的PENDING事件,校验费用版本、原事件ID及载荷摘要;沿用原事件及资金幂等身份,不创建费用或资金指令。同一请求UUID重复提交仅返回原操作,跨费用或事件复用拒绝。无失败原因的PENDING、PROCESSING和PUBLISHED不可新发起人工重试;每次重试失败后可再次提交新的请求UUID继续重推。写入既有event_delivery_retry_audit审计。 + required: [expectedVersion, eventId, idempotencyKey] + properties: + expectedVersion: { type: integer, minimum: 1 } + eventId: { type: string, maxLength: 64 } + idempotencyKey: { type: string, format: uuid } + ActionRequest: + type: object + description: submit、cancel、retry-funding共用;标识需按费用及单次操作生成,重试原操作沿用。 + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + ReviewRequest: + type: object + required: [decision, idempotencyKey] + properties: + decision: { type: string, enum: [APPROVE, REJECT] } + expectedVersion: { type: integer, minimum: 1, description: 仓租审核必填,必须为审核人看到的当前费用版本;调价后旧版审核被拒绝。 } + reason: { type: string, maxLength: 500, nullable: true } + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + AdjustRequest: + type: object + required: [expectedVersion, fee, idempotencyKey] + properties: + expectedVersion: { type: integer, minimum: 1 } + fee: { $ref: '#/components/schemas/SaveRequest' } + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + VoidRequest: + type: object + required: [expectedVersion, reason, idempotencyKey] + properties: + expectedVersion: { type: integer, minimum: 1 } + reason: { type: string, minLength: 1, maxLength: 500 } + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, PROCUREMENT_FEE, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER, DISCARD_ORDER] } + BusinessOption: + type: object + properties: + businessId: { type: string } + businessType: { $ref: '#/components/schemas/BusinessType' } + businessNo: { type: string } + customerId: { type: string } + customerName: { type: string, nullable: true, description: 查询显示按客户ID读取IAM当前名称;主档不可用时回退历史名称,查询不回写费用或结算快照。 } + warehouseId: { type: string, nullable: true } + orderId: { type: string, nullable: true } + orderType: { type: string, nullable: true } + FundingDescription: + type: object + description: 海外费用来源返回值的资金补充字段。amount及currency始终为CNY结算快照;originalAmount、originalCurrency、exchangeRate、exchangeRateMonth保留原币依据。 + properties: + displayBusinessType: + allOf: [{ $ref: '#/components/schemas/BusinessType' }] + readOnly: true + description: 按真实业务事实解析的展示分类;分页业务类型筛选使用此分类。换标履约费用显示RELABEL_ORDER,原businessType/businessId保留收费凭据的关联身份。新登记换标履约费用以真实换标申请作为主业务关联。 + storageRent: + type: object + nullable: true + readOnly: true + description: 自动仓租的原始计费与实际出入库明细;审核前可通过仓租adjust入口留说明调价。chargedAmount仅成功实扣或确认零元后返回。 + properties: + outboundNo: {type: string} + outboundAt: {type: string, format: date-time} + calculatedAmount: {type: number, description: 原快照计算金额,币种为originalCurrency} + chargedAmount: {type: number, nullable: true, description: 实际扣费CNY,未成功时为null} + lines: + type: array + items: + type: object + properties: + lotId: {type: string} + sourceBusinessType: {type: string, nullable: true, description: 原计租入库来源类型,头程为LASTLEG_ORDER,退货为RETURN_ORDER} + sourceBusinessNo: {type: string, nullable: true, description: 原计租来源单号,头程展示对应尾程订单号,退货展示退货单号;不以换标出库单替代} + batchNo: {type: string, nullable: true, description: 原计租入库批次} + inboundAt: {type: string, format: date-time} + quantity: {type: number} + days: {type: integer} + calculatedAmount: {type: number, description: 本原批次原币计费依据} + collectionMode: { type: string, enum: [BILL, WALLET] } + walletStatus: { type: string, enum: [NOT_REQUIRED, FREEZE_PENDING, FROZEN, FREEZE_FAILED, SETTLE_PENDING, SETTLED, SETTLE_FAILED, RELEASE_PENDING, RELEASED, RELEASE_FAILED] } + walletErrorMessage: { type: string, nullable: true } + deliveryStatus: { type: string, nullable: true, enum: [PENDING, PROCESSING, PUBLISHED, DEAD], description: 当前版本原事件的实际投递状态;账单费用publicationStatus=PUBLISHED仅表示已入队,不代表Finance已接收;余额应收实扣成功即完成且deliveryStatus为空。 } + deliveryKind: { type: string, nullable: true, enum: [FEE, FUNDING] } + deliveryEventId: { type: string, nullable: true, description: 当前原事件ID,重试时必须原样提交 } + deliveryErrorMessage: { type: string, nullable: true } + allowedActions: { type: array, items: { type: string, enum: [SUBMIT, APPROVE, REJECT, ADJUST, ADJUST_RENT, VOID, RETRY_FREEZE, RETRY_SETTLE, RETRY_RELEASE, RETRY_PUBLISH, RETRY_FUNDING_DELIVERY] } } + reviewerId: { type: string, nullable: true, description: 审核人ID } + reviewerName: { type: string, nullable: true, description: 审核人名称快照 } + reviewedAt: { type: string, format: date-time, nullable: true, description: 审核时间,UTC 且携带 Z 时区 } + createdAt: { type: string, format: date-time, nullable: true, description: 创建时间,UTC 且携带 Z 时区 } + updatedAt: { type: string, format: date-time, nullable: true, description: 更新时间,UTC 且携带 Z 时区 } OverseasContainerFeeItem: type: string description: 海外阶段柜级费用项目:港口费、堆存费、修箱费、吊机费、码头拖车费、其他。 @@ -67,12 +234,12 @@ components: timestamp: { type: string, format: date-time } SaveRequest: type: object - required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] + required: [customerId, sourceType, businessType, businessId, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] properties: customerId: { type: string } sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] } - businessType: { type: string, enum: [OVERSEAS_INBOUND, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] } - businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } + businessType: { type: string, enum: [OVERSEAS_INBOUND, US_TRUCK, LOCAL_LOOSEBOARD, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER, DISCARD_ORDER] } + businessId: { type: string, pattern: '^[1-9][0-9]*$' } businessNo: { type: string, maxLength: 128 } containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } containerNo: { type: string, maxLength: 64, nullable: true } @@ -81,8 +248,9 @@ components: inboundCode: { type: string, maxLength: 64, nullable: true } feeItemCode: type: string - description: OVERSEAS_CONTAINER时必须取OverseasContainerFeeItem枚举;其他业务类型沿用各自费用项目编码。 + description: 必须选择共享费用目录的有效编码,人工录费禁止自动仓租项目FEE_TYPE_077。OVERSEAS_CONTAINER时同时符合OverseasContainerFeeItem枚举。 feeItemName: { type: string } + feeRemark: { type: string, maxLength: 500, nullable: true, description: 费用说明;同单同方向已有相同项目时必填,允许实际作业分次收费。已驳回原费用终结,只能重新新增。 } direction: { type: string, enum: [RECEIVABLE, PAYABLE] } businessDate: { type: string, format: date } currency: { type: string, enum: [CNY, USD], description: 业务原币;USD由服务端按业务月份有效汇率折算为CNY入池 } diff --git a/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml index 159d70577..2cfa18852 100644 --- a/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 采购样品履约节点 API version: 1.0.0 - description: 客户范围、节点资格、版本与幂等由服务端校验;购买事实与资金结果分开保存。 + description: 客户范围、节点资格、版本与幂等由服务端校验;购买事实与资金结果分开保存。采购双方向复用两段技术编码:OVERSEAS 为起运段,DOMESTIC 为目的地段;按 procurementDirection 解释实际区域。 security: - bearerAuth: [] paths: @@ -2310,7 +2310,6 @@ components: maxLength: 500 ChargeSelection: type: object - required: [] properties: feeId: type: string @@ -2977,6 +2976,8 @@ components: - status - version properties: + procurementDirection: + $ref: 'overseas-business-v1.yaml#/components/schemas/ProcurementDirection' orderNo: type: string nullable: true diff --git a/lastleg-service/src/main/resources/openapi/storage-rent-v1.yaml b/lastleg-service/src/main/resources/openapi/storage-rent-v1.yaml new file mode 100644 index 000000000..2863a5b71 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/storage-rent-v1.yaml @@ -0,0 +1,227 @@ +openapi: 3.0.3 +info: + title: 海外仓租与出库资金预校验 + version: 1.0.0 + description: | + 仅仓租自动计费。中转按CBM/天、退货按原托份额/天累进,首次或重建预校验读取客户当前仓租配置。 + 未配置客户使用仓库服务明确返回的两张默认报价表;网络、无效配置或不支持币种均阻断出库,不降级免费。 + PlanJson保存配置ID、版本、币种和完整阶梯。有效预占重放与consume使用原快照及冻结金额,不受期间改价影响。 + 后续部分出库采用客户最新配置,原入库时间与原托份额保持不变。原币支持USD/CNY,结算币为CNY。 + 实际PDA入库开始计租,实际出库按本次数量生成待审核费用,审核通过后按确认金额扣款;所有海外仓租从余额及有效授信扣除。 + 头程同单同仓多次入库合并计租,统一沿用首次实际入库时间;其他业务按各批实际入库时间分别计龄。 + 每满24小时一天,不足24小时向上取整。公开时间均为ISO8601 UTC时间,携带Z,客户端按本地时区显示。 + 普通业务错误沿用HTTP 200及非200业务code。平台接口要求平台tenant_id=0。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/storage-rent: + get: + summary: 原计租批次分页 + description: 权限platform:overseas-fee:list。一个原始实收批次一行。 + parameters: + - {in: query, name: customerId, schema: {type: string}} + - {in: query, name: businessNo, schema: {type: string}} + - {in: query, name: warehouseId, schema: {type: string}} + - {in: query, name: businessType, schema: {type: string, enum: [LASTLEG_ORDER, WAREHOUSE_TRANSFER, RETURN_ORDER]}} + - {in: query, name: status, schema: {type: string, enum: [IN_STOCK, CLEARED]}} + - {in: query, name: page, schema: {type: integer, minimum: 1, default: 1}} + - {in: query, name: size, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 统一响应data包含records、page、size、total、pages + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Lot'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + /api/platform/v1/last-leg/storage-rent/warehouse-options: + get: + summary: 计租仓库选项 + description: 权限platform:overseas-fee:list,只读取已有仓库快照。 + responses: + '200': + description: data为id、warehouseName对象数组 + /api/platform/v1/last-leg/storage-rent/{id}: + get: + summary: 原批次及逐次出库仓租 + description: 权限platform:overseas-fee:list;只返回已实际出库的收费记录,免费出库保留零金额。 + parameters: + - {$ref: '#/components/parameters/id'} + responses: + '200': + description: 统一响应 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + properties: + lot: {$ref: '#/components/schemas/Lot'} + charges: {type: array, items: {$ref: '#/components/schemas/Charge'}} + /api/platform/v1/last-leg/storage-rent/fees/{sourceFeeId}/adjust: + post: + summary: 审核前调整仓租金额并保存说明 + description: 权限platform:finance:fee-source:edit。仅当前待审核且资金已锁定版本可改;金额按原币填写,保留冻结汇率与原计算快照。生成新费用版本。原零元改为非零须先补冻结;原非零费用调高在审核结算时由Finance原子检查差额,余额不足保留原冻结,不登记实扣。 + parameters: + - {in: path, name: sourceFeeId, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion, amount, reason, idempotencyKey] + properties: + expectedVersion: {type: integer, minimum: 1} + amount: {type: number, minimum: 0, maximum: 999999999999.99, multipleOf: 0.01} + reason: {type: string, minLength: 1, maxLength: 500} + idempotencyKey: {type: string, minLength: 1, maxLength: 128} + responses: + '200': {description: 统一响应,data为调整后的费用版本;状态冲突或缺少说明返回非200业务code} + /api/pda/v1/last-leg/storage-rent/preparations: + post: + summary: 出库前计算仓租并提交余额授信冻结 + description: | + 权限lastleg:pda:operate及ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR。客户与仓库从绑定身份及真实业务来源解析。 + ready=false必须等待资金回执;仅ready=true可提交原扫码。先冻结后扣库存,实际扫码后生成待审核仓租,审核通过后实际扣款。 + 同一原扫码复用idempotencyKey与全部明细;更改货物或数量必须新键。冻结15分钟有效,变更计费天数或汇率月份需重算。 + 已实际出库的原键返回CONSUMED,调用原扫码接口恢复其幂等结果。零费用无需汇率及资金指令。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/PrepareRequest'} + responses: + '200': + description: 统一响应data为预校验状态 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/Preparation'} + /api/pda/v1/last-leg/storage-rent/preparations/{id}: + parameters: + - {$ref: '#/components/parameters/id'} + get: + summary: 查询原冻结真实回执 + description: PDA权限及绑定仓校验同创建。 + responses: + '200': {description: 统一响应data为Preparation} + delete: + summary: 取消未使用预占并解冻 + description: 已出库不能取消;冻结处理中取消将于迟到成功回执后补解冻。PDA权限及绑定仓校验同创建。 + responses: + '200': {description: 统一响应data为Preparation} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + id: {in: path, name: id, required: true, schema: {type: string}} + schemas: + Envelope: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {type: object} + Lot: + type: object + properties: + id: {type: string} + customerId: {type: string} + customerName: {type: string} + businessType: {type: string} + businessNo: {type: string, description: 原计租来源单号,头程为对应尾程订单号,退货为退货单号} + warehouseId: {type: string} + warehouseName: {type: string} + batchNo: {type: string} + inboundAt: {type: string, format: date-time, description: 实际入库UTC时间,带Z} + storageDays: {type: integer, description: 已清仓按最后实际出库停止} + originalQuantity: {type: number} + remainingQuantity: {type: number} + quantityUnit: {type: string, enum: [PACKAGE, PIECE]} + billingQuantity: {type: number} + billingUnit: {type: string, enum: [CBM, PALLET]} + ruleCode: {type: string, enum: [TRANSIT_V1, RETURN_V1]} + status: {type: string, enum: [IN_STOCK, CLEARED]} + settledAmount: {type: number, description: 本原批次已成功扣款的CNY合计,不含处理中及失败记录} + chargeCount: {type: integer, description: 本原批次实际出库仓租记录数,包含免租记录} + settledChargeCount: {type: integer, description: 已完成结算的仓租记录数,包含免租记录} + generatedAmount: {type: number, nullable: true, description: 单币种合计;无记录为0,多币种为null} + generatedCurrency: {type: string, nullable: true, enum: [USD, CNY], description: 无记录或多币种为null} + generatedAmounts: + type: array + description: 按原币分别汇总,禁止跨币相加;无记录为空数组 + items: + type: object + properties: + currency: {type: string, enum: [USD, CNY]} + amount: {type: number} + Charge: + type: object + properties: + id: {type: string} + outboundNo: {type: string} + businessNo: {type: string} + quantity: {type: number} + storageDays: {type: integer} + originalAmount: {type: number} + currency: {type: string, enum: [USD, CNY]} + settlementAmount: {type: number, description: CNY结算金额} + currencyRate: {type: number, nullable: true} + status: {type: string, enum: [PENDING_REVIEW, REVIEW_FREEZE_PENDING, REVIEW_FREEZE_FAILED, REJECTED, CONSUMED, SETTLE_FAILED, SETTLED]} + createdAt: {type: string, format: date-time} + outboundAt: {type: string, format: date-time} + PrepareRequest: + type: object + required: [mode, businessNo, idempotencyKey] + properties: + mode: {type: string, enum: [DISPATCH, OUTBOUND, OUTBOUND_PALLET, DISCARD]} + businessNo: {type: string, maxLength: 128} + itemId: {type: string} + positionId: {type: string} + packages: {type: integer, minimum: 1} + cartonMark: {type: string} + palletNo: {type: string} + idempotencyKey: {type: string, maxLength: 255} + cartonMarks: {type: array, items: {type: string}} + skuCode: {type: string} + productQuantity: {type: integer, minimum: 0} + lines: + type: array + items: + type: object + required: [itemId, actualPackages] + properties: + itemId: {type: string} + actualPackages: {type: integer, minimum: 0} + photoFileIds: {type: array, items: {type: string}} + scannedCartonMarks: {type: array, items: {type: string}} + confirmAllCartons: {type: boolean} + Preparation: + type: object + properties: + preparationId: {type: string, nullable: true} + status: {type: string, enum: [NOT_REQUIRED, FREEZE_PENDING, READY, FAILED, EXPIRED, CANCELLED, CONSUMED]} + ready: {type: boolean} + amount: {type: number} + currency: {type: string, enum: [CNY]} + originalAmount: {type: number, nullable: true, description: 整托包含多种原币时为null,CNY amount始终可合计} + originalCurrency: {type: string, nullable: true, enum: [USD, CNY]} + exchangeRate: {type: number, nullable: true} + exchangeRateMonth: {type: string, nullable: true} + message: {type: string, nullable: true} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java index 6b806fbe6..2b10da0e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java @@ -48,22 +48,22 @@ class DispatchDeliveryFeesTest { } /** 无引用时不强制Finance可用,已有外接约定组禁止新增派送应收。 */ - @Test void ordinaryNeedsPositiveFeeAndExternalQuoteDoesNotWaiveOtherCargo() { + @Test void ordinaryAllowsLaterManualFeeAndOldReceivableInputIsRejected() { var row=cargo(1,"101","ONT8",51,2);var group=grouping.groups(context(List.of(row),false)).get(0); var input=input(group,List.of(),List.of()); assertNull(service.coverage(group,input,"OP",ordinary()));verifyNoInteractions(finance); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,null,ordinary())); - service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary()); + service.validate(group,input,null,ordinary()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary())); var quote=new FleetBookingFreight.Quote("BOOK-1",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO); var allExternal=new FleetBookingFreight.Preview("freight",false,List.of(quote),List.of()); service.validate(group,input,null,allExternal); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,allExternal)); var mixed=grouping.groups(context(List.of(row,cargo(2,"101","ONT8",51,3)),false)).get(0); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal)); + service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal); } /** 正数报价发布到自己的客户目的地,零约定仅保存商业操作不造零费用。 */ - @Test void zeroAgreementRecordsFactAndPositiveFeeUsesOwnDelivery() { + @Test void zeroAndPositiveQuotesKeepFactsWithoutGeneratingReceivables() { var row=cargo(1,"101","ONT8",51,2);var context=context(List.of(row),false);var group=grouping.groups(context).get(0); var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001"); var zero=new FleetBookingFreight.Quote("BOOK-ZERO",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO); @@ -71,9 +71,7 @@ class DispatchDeliveryFeesTest { verify(repository).operation(any());verifyNoInteractions(sources); var positive=new FleetBookingFreight.Quote("BOOK-POSITIVE",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.TEN,BigDecimal.ONE,"单",BigDecimal.TEN); service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(positive),List.of()),"OP","ARR-1"); - var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);verify(sources).publishApproved(eq("OP"),anyString(),saved.capture()); - assertEquals("101",saved.getValue().customerId());assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo()); - assertEquals(BigDecimal.TEN,saved.getValue().amount()); + verify(repository,times(2)).operation(any()); verifyNoInteractions(sources); } /** 合托两票一笔原费只能覆盖对应来源,不得免掉另一票新增应收。 */ @@ -88,7 +86,7 @@ class DispatchDeliveryFeesTest { var evidence=service.coverage(group,input,"OP",ordinary()); assertEquals(2,evidence.cargo().size()); verify(finance).sources("offline-test-token",new FinanceFeeCoverageClient.Query(101L,List.of(501L,502L))); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,evidence,ordinary())); + service.validate(group,input,evidence,ordinary()); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group, input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary())); } @@ -102,7 +100,7 @@ class DispatchDeliveryFeesTest { var group=grouping.groups(context).get(0); var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101"); delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001"); - service.publish(context,group,input(group,List.of(),List.of(fee("RECEIVABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL"); + service.publish(context,group,input(group,List.of(),List.of(fee("PAYABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL"); var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class); verify(sources).publishApproved(eq("OP"),anyString(),saved.capture()); assertEquals("DELIVERY_ORDER",saved.getValue().businessType()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index e399e12c7..62ab9d7d9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -133,6 +133,7 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); var quantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository(); ReflectionTestUtils.setField(quantities, "pallets", outboundPallets); ReflectionTestUtils.setField(service, "outboundQuantities", quantities); @@ -451,22 +452,6 @@ class DeliveryTransferServiceTest { - @org.junit.jupiter.params.ParameterizedTest - @org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"}) - - - - - - - - - - - - - - @Test void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java index c0c0d96eb..b8aac0de9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java @@ -37,7 +37,8 @@ class RelabelOutboundPickingTest { /** 构造返工后新目标位:两箱冻结,账面混合SKU,与空出库SKU不作通配匹配。 */ @BeforeEach void prepare() { - service = new DeliveryTransferService(); commands = mock(InventoryCommandService.class); + service = new DeliveryTransferService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); commands = mock(InventoryCommandService.class); picks=mock(OutboundPdaPickMapper.class); tasks=mock(OutboundPdaTaskMapper.class); orders=mock(OutboundOrderMapper.class); pallets=mock(OutboundPalletItemMapper.class); positions=mock(InventoryPositionMapper.class); balances=mock(InventoryBalanceMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index 440a02860..75e54afb6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -57,6 +57,7 @@ class ReturnOutboundClosureTest { ReturnInventoryCarton carton; @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "storageRent", mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); // 取消入口先查询幂等记录,手动组装的测试服务也需要注入该仓储。 ReflectionTestUtils.setField(service, "cancellations", new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( outbounds, items, deliveries, tasks, mock(DeliveryOperationMapper.class), relabelApplications, cartons, @@ -263,6 +264,7 @@ class ReturnOutboundClosureTest { var relabelMapper = traced(sessions.getMapper(RelabelApplicationMapper.class), RelabelApplicationMapper.class, "lockByOutbound", "relabel"); var cartonMapper = traced(sessions.getMapper(ReturnInventoryCartonMapper.class), ReturnInventoryCartonMapper.class, "lockMark", "carton"); var target = new DeliveryTransferService(); + org.springframework.test.util.ReflectionTestUtils.setField(target, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(target,"cancellations",new DeliveryCancellationRepository(outboundMapper,sessions.getMapper(OutboundPalletItemMapper.class), sessions.getMapper(DeliveryOrderMapper.class),sessions.getMapper(OutboundPdaTaskMapper.class),sessions.getMapper(DeliveryOperationMapper.class),relabelMapper, cartonMapper,sessions.getMapper(WarehouseTransferOrderMapper.class),sessions.getMapper(LastlegOrderMapper.class),sessions.getMapper(LastlegOrderStatusHistoryMapper.class))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index 48716f73e..8a202f0e3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -65,6 +65,7 @@ class ReturnOutboundPdaPersistenceTest { movementMapper = session.getMapper(ReturnInventoryMovementMapper.class); balanceMapper = session.getMapper(ReturnInventoryBalanceMapper.class); var target = new DeliveryTransferService(); + org.springframework.test.util.ReflectionTestUtils.setField(target, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(target, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(target, "returnCartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class)); ReflectionTestUtils.setField(target, "deliveryOrders", session.getMapper(DeliveryOrderMapper.class)); @@ -74,6 +75,7 @@ class ReturnOutboundPdaPersistenceTest { ReflectionTestUtils.setField(target, "pdaPicks", session.getMapper(OutboundPdaPickMapper.class)); ReflectionTestUtils.setField(target, "returnCartons", session.getMapper(ReturnInventoryCartonMapper.class)); var stock = new ReturnInventoryService(); + org.springframework.test.util.ReflectionTestUtils.setField(stock, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(stock, "cartons", session.getMapper(ReturnInventoryCartonMapper.class)); ReflectionTestUtils.setField(stock, "cartonItems", session.getMapper(ReturnInventoryCartonItemMapper.class)); ReflectionTestUtils.setField(stock, "balances", session.getMapper(ReturnInventoryBalanceMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index 7980ea411..4e51d10ad 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest { @BeforeEach void setup() { service = new PdaCargoDiscardService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository(); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index ed641cf02..8e69092e7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -119,6 +119,7 @@ class CargoDiscardServiceTest { @BeforeEach void setUp() { service = new CargoDiscardService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository(); ReflectionTestUtils.setField(quantities, "items", items); ReflectionTestUtils.setField(service, "quantities", quantities); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index a7d8a6281..9ce97c28b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -633,6 +633,7 @@ public class DispatchAggregatePersistenceTest { service=new DispatchAggregateService(repository, new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger(); when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet())); org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 7a124ba99..a577e9900 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -23,6 +23,20 @@ import static org.mockito.Mockito.*; /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ class DispatchPickingPersistenceTest { + /** 仓租冻结未确认时阻断真实扫码,任务、库存及幂等记录全部回滚。 */ + @Test void storageRentFundingGateBlocksActualInventoryAndRollsBackTask() throws Exception { + var fixture=new Picking(); + var rent=mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class); + doThrow(new com.baiyue.common.core.exception.BizException("仓租资金尚未冻结")).when(rent).consume(eq("9"),any()); + org.springframework.test.util.ReflectionTestUtils.setField(fixture.service,"storageRent",rent); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, + new DispatchCommands.Pick(fixture.version,"rent-funding-blocked",11L,3,0,null,null)))).hasMessageContaining("尚未冻结"); + verify(rent).consume(eq("9"),argThat(request->"rent-funding-blocked".equals(request.idempotencyKey())&&request.packages()==3L)); + verify(fixture.inventory,never()).ship(any(),any(),any()); + assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isEqualTo(3); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isZero(); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='rent-funding-blocked'",Long.class)).isZero(); + } /** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */ @Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception { var fixture=new Picking(true);fixture.receivedPlans(9,11); @@ -119,6 +133,7 @@ class DispatchPickingPersistenceTest { sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class))); var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); } /** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java index 95dbffcf3..e970d4f15 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java @@ -17,6 +17,7 @@ class DispatchRegionalCandidatesTest { @Test void serviceAcceptsRegionalQueriesAndRejectsMalformedScopes() { var sources=mock(DispatchService.class);var appointments=mock(DispatchAppointmentAllocations.class); var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper()); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service,"appointments",appointments); var actor=new Actor("7",ActorMode.PLATFORM,null); when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index 68502950b..35b7a11f6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -131,7 +131,8 @@ class DispatchRepairWorkflowTest { var execution=new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class));dependencies.put(DeliveryExecutionRepository.class,execution);dependencies.put(DeliveryExecutionGuard.class,new DeliveryExecutionGuard(execution)); dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class))); fees=mock(OverseasFeeSourceService.class);dependencies.put(OverseasFeeSourceService.class,fees); - delivery=wire(new DeliveryTransferService());visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService()); + delivery=wire(new DeliveryTransferService()); + org.springframework.test.util.ReflectionTestUtils.setField(delivery, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService()); org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("OP","", "platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:cancel","platform:overseas-warehouse:dispatch:transfer-delivery")); } /** 真实权限上下文不泄漏到其他测试。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/FeeCatalogGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/FeeCatalogGatewayTest.java new file mode 100644 index 000000000..b61d089a9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/FeeCatalogGatewayTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.client.FirstlegFeeCatalogClient; +import com.baiyue.lastleg.financefee.service.FeeCatalogGateway; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FeeCatalogGatewayTest { + /** 应付项目按真实方向查询,非法方向不访问远端目录。 */ + @Test void listsPayableItemsAndRejectsUnknownDirection() { + FirstlegFeeCatalogClient client = mock(FirstlegFeeCatalogClient.class); + FeeCatalogGateway gateway = new FeeCatalogGateway(client, "token"); + when(client.list("token", "PAYABLE")).thenReturn(ApiResponse.success(List.of( + new FirstlegFeeCatalogClient.Item("FEE-2", "供应商运费", BigDecimal.TEN)))); + assertEquals("FEE-2", gateway.listActive("PAYABLE").get(0).code()); + assertThrows(BizException.class, () -> gateway.listActive("OTHER")); + verify(client).list("token", "PAYABLE"); + verifyNoMoreInteractions(client); + } + /** 费用必须使用目录当前编码与名称,不接受伪造或过时项目快照。 */ + @Test void validatesTheCurrentCatalogSnapshot() { + FirstlegFeeCatalogClient client = mock(FirstlegFeeCatalogClient.class); + FeeCatalogGateway gateway = new FeeCatalogGateway(client, "token"); + FirstlegFeeCatalogClient.Item item = new FirstlegFeeCatalogClient.Item("FEE-1", "操作费", new BigDecimal("2.50")); + when(client.list("token", "RECEIVABLE")).thenReturn(ApiResponse.success(List.of(item))); + when(client.detail("token", "FEE-1", "RECEIVABLE")).thenReturn(ApiResponse.success(item)); + assertEquals(item.defaultPrice(), gateway.listActiveReceivable().get(0).defaultPrice()); + assertDoesNotThrow(() -> gateway.requireActive("FEE-1", "操作费", "RECEIVABLE")); + assertThrows(BizException.class, () -> gateway.requireActive("FEE-1", "篡改项目名", "RECEIVABLE")); + } + + /** 凭证未配置及远端拒绝均关闭录费,不能转为任意费用项。 */ + @Test void failsClosedOnMissingIdentityAndRejectedResponses() { + FirstlegFeeCatalogClient client = mock(FirstlegFeeCatalogClient.class); + assertThrows(BizException.class, () -> new FeeCatalogGateway(client, "").listActiveReceivable()); + verifyNoInteractions(client); + FeeCatalogGateway gateway = new FeeCatalogGateway(client, "token"); + when(client.list("token", "RECEIVABLE")).thenReturn(ApiResponse.failure(ResultCode.FORBIDDEN, "拒绝", null, null)); + assertThrows(BizException.class, gateway::listActiveReceivable); + when(client.detail("token", "FEE-1", "RECEIVABLE")).thenReturn(ApiResponse.success(null)); + assertThrows(BizException.class, () -> gateway.requireActive("FEE-1", "操作费", "RECEIVABLE")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java index 3282b4e79..964e40c2a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java @@ -64,7 +64,7 @@ class OverseasFeeCancellationPersistenceTest { jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(200) UNIQUE,payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)"); - jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32),wallet_settlement VARCHAR(1000),review_reason VARCHAR(500),reviewer_id VARCHAR(128),reviewer_name VARCHAR(128),reviewed_at TIMESTAMP)"); jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status) VALUES(10,701,1,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE')"); var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); config.addMapper(OverseasFeeSourceMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); @@ -106,6 +106,17 @@ class OverseasFeeCancellationPersistenceTest { assertThat(ready).hasSize(1); assertThat(ready.get(0).getEventKey()).isEqualTo(OverseasFeeCancellation.eventKey(source, "PUBLISHED")); } + /** 旧余额应收即使等待重试也不再发送,并在分页前排除,不挤占账单投递。 */ + @Test void collectedReceivablesDoNotOccupyPublicationQueue() { + for (int i = 0; i < 60; i++) { + long id = 2000 + i; + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,direction,wallet_settlement) VALUES(?,?,1,'10001','OVERSEAS_DELIVERY_FEE','RECEIVABLE','{}')", id, id); + jdbc.update("INSERT INTO ll_event_outbox(id,customer_id,aggregate_type,aggregate_id,event_type,event_key,status,created_at) VALUES(?,'10001','OVERSEAS_DELIVERY_FEE',?,?,?,'PENDING',?)", + id, String.valueOf(id), request.getEventType(), "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_DELIVERY_FEE:" + id + ":1:PUBLISHED", now.minusDays(1)); + } + assertThat(repository.pending(50, now.plusDays(1), now.minusSeconds(60))).extracting(LastlegEventOutbox::getEventKey) + .containsExactly(OverseasFeeCancellation.eventKey(source, "PUBLISHED")); + } @Test void manualRetryInShanghaiIsImmediatelySelectableAndClaimableByUtcFeeRelay() { TimeZone original = TimeZone.getDefault(); try { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeResponseTimeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeResponseTimeTest.java new file mode 100644 index 000000000..3ebf827cd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeResponseTimeTest.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.lastleg.financefee.config.OverseasFeeResponseJsonConfiguration; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService.RetryRecord; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import static org.assertj.core.api.Assertions.*; + +/** 费用与出库时间必须表达同一时刻,业务录入的本地支付时间不能被误转 UTC。 */ +class OverseasFeeResponseTimeTest { + @Test void feeSystemTimesAndRetryExposeUtcWithoutChangingStoredValues() throws Exception { + ObjectMapper json = Jackson2ObjectMapperBuilder.json().modulesToInstall( + new OverseasFeeResponseJsonConfiguration().overseasFeeResponseTimeModule()).build(); + var utc = LocalDateTime.of(2026, 10, 23, 10, 30); + var fee = new OverseasFeeSource(); + fee.setCreatedAt(utc); fee.setReviewedAt(utc); fee.setUpdatedAt(utc); fee.setPaidAt(utc); + var document = json.readTree(json.writeValueAsString(fee)); + for (var field : new String[]{"createdAt", "reviewedAt", "updatedAt"}) { + assertThat(document.get(field).asText()).isEqualTo("2026-10-23T10:30:00Z"); + assertThat(OffsetDateTime.parse(document.get(field).asText()).toInstant()) + .isEqualTo(OffsetDateTime.parse("2026-10-23T18:30:00+08:00").toInstant()); + } + assertThat(fee.getCreatedAt()).isEqualTo(utc); + assertThat(document.get("paidAt").asText()).doesNotEndWith("Z"); + var retry = json.readTree(json.writeValueAsString(new RetryRecord("key", utc, "审核员", "重试", "SUCCESS", null))); + assertThat(retry.get("requestedAt").asText()).isEqualTo("2026-10-23T10:30:00Z"); + fee.setReviewedAt(null); + assertThat(json.readTree(json.writeValueAsString(fee)).get("reviewedAt").isNull()).isTrue(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java new file mode 100644 index 000000000..a3f27c4fb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasManualFeePersistenceTest.java @@ -0,0 +1,229 @@ +package com.baiyue.lastleg.financefee; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeBusinessMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeFundingMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasManualFeeQueryMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class OverseasManualFeePersistenceTest { + private JdbcTemplate jdbc; + private OverseasFeeFundingMapper funding; + private OverseasManualFeeQueryMapper queries; + private OverseasFeeBusinessMapper businesses; + private OverseasFeeSourceMapper fees; + + /** 在隔离内存库验证真正Mapper与DDL,无需启动服务或连接测试环境。 */ + @BeforeEach void setup() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + String ddl = Files.readString(Path.of("src/main/resources/db/overseas-manual-fee-funding.sql")) + .replace("command_history JSON", "command_history VARCHAR(10000)"); + jdbc.execute("CREATE TABLE ll_destuff_pallet_cost_allocation(fee_source_id BIGINT NOT NULL)"); + jdbc.execute(ddl); + jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(128),customer_name VARCHAR(200),business_type VARCHAR(64),business_no VARCHAR(128),fee_item_code VARCHAR(64),direction VARCHAR(16),review_status VARCHAR(16),created_at TIMESTAMP,exchange_rate DECIMAL(20,6),wallet_settlement VARCHAR(1000),payment_voucher_file_ids VARCHAR(1000))"); + jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),customer_name VARCHAR(200),order_no VARCHAR(128),order_type VARCHAR(32),overseas_warehouse_id VARCHAR(64))"); + for (String table : java.util.List.of("ll_warehouse_transfer_order", "ll_return_order", "ll_us_truck_order", "ll_local_looseboard_order", "ll_relabel_application", "ll_cargo_discard_application", "ll_inbound_plan", "ll_delivery_order")) + jdbc.execute("CREATE TABLE " + table + "(customer_id VARCHAR(128))"); + var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + config.addMapper(OverseasFeeSourceMapper.class); config.addMapper(OverseasFeeFundingMapper.class); + config.addMapper(OverseasManualFeeQueryMapper.class); config.addMapper(OverseasFeeBusinessMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); + var session = new SqlSessionTemplate(factory.getObject()); + funding = session.getMapper(OverseasFeeFundingMapper.class); queries = session.getMapper(OverseasManualFeeQueryMapper.class); + businesses = session.getMapper(OverseasFeeBusinessMapper.class); + fees = session.getMapper(OverseasFeeSourceMapper.class); + } + + /** 指令历史JSON必须随行锁读回,成功时旧错误必须能清空。 */ + @Test void fundingHistoryRoundTripsAndSuccessClearsPreviousError() { + var value = new OverseasFeeFunding(); value.setSourceFeeId(701L); value.setCustomerId("10001"); + value.setCollectionMode("WALLET"); value.setWalletStatus("FREEZE_FAILED"); value.setCommandVersion(1L); + value.setCommandHistory(new LinkedHashMap<>(Map.of("command-1", "FREEZE"))); value.setErrorMessage("余额不足"); + value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(LocalDateTime.now()); funding.insert(value); + var locked = funding.lock(701L); assertThat(locked.getCommandHistory()).containsEntry("command-1", "FREEZE"); + locked.setErrorMessage(null); locked.setWalletStatus("FROZEN"); funding.updateById(locked); + assertThat(funding.lock(701L).getErrorMessage()).isNull(); + } + + /** 分页只查最新版本,并保持客户、应收方向和资金状态筛选一致。 */ + @Test void pageSelectsLatestVersionAndFiltersCustomerDirectionAndFunding() { + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(1,701,1,'10001','客户甲','WAREHOUSE_TRANSFER','TR-01','OUTBOUND','RECEIVABLE','DRAFT',CURRENT_TIMESTAMP,NULL,NULL)"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(2,701,2,'10001','客户甲','WAREHOUSE_TRANSFER','TR-01','OUTBOUND','RECEIVABLE','PENDING',CURRENT_TIMESTAMP,'{\"commandEventId\":\"e\",\"holdId\":\"h\",\"ledgerId\":\"l\"}','[\"proof\"]')"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(3,702,1,'10002','客户乙','WAREHOUSE_TRANSFER','TR-02','OUTBOUND','RECEIVABLE','PENDING',CURRENT_TIMESTAMP,NULL,NULL)"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,customer_name,business_type,business_no,fee_item_code,direction,review_status,created_at,wallet_settlement,payment_voucher_file_ids) VALUES(4,703,1,'10001','客户甲','WAREHOUSE_TRANSFER','TR-03','OUTBOUND','PAYABLE','PENDING',CURRENT_TIMESTAMP,NULL,NULL)"); + var query = new HashMap(); query.put("customerId", "10001"); query.put("direction", "RECEIVABLE"); + query.put("offset", 0); query.put("size", 20); + assertThat(queries.count(query)).isEqualTo(1); + var rows = queries.page(query); assertThat(rows).hasSize(1); assertThat(rows.get(0).getSourceVersion()).isEqualTo(2); + assertThat(rows.get(0).getWalletSettlement().ledgerId()).isEqualTo("l"); + assertThat(rows.get(0).getPaymentVoucherFileIds()).containsExactly("proof"); + query.put("walletStatus", "SETTLED"); assertThat(queries.count(query)).isEqualTo(1); + query.put("walletStatus", "FREEZE_PENDING"); assertThat(queries.count(query)).isZero(); + } + + /** 仓租专页只返回已出库且同客户的真实仓租,不混入同名人工费用或未消费准备。 */ + @Test void storageRentOnlyRequiresConsumedOwnedPreparation() { + jdbc.execute("CREATE TABLE ll_storage_rent_preparation(source_fee_id BIGINT,customer_id VARCHAR(128),consumed_at TIMESTAMP)"); + for (int index = 1; index <= 4; index++) + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,fee_item_code,direction) VALUES(?,?,1,'10001','FEE_TYPE_077','RECEIVABLE')", index, 700 + index); + jdbc.update("INSERT INTO ll_storage_rent_preparation VALUES(701,'10001',CURRENT_TIMESTAMP),(702,'10001',NULL),(703,'10002',CURRENT_TIMESTAMP)"); + var query = new HashMap(); query.put("storageRentOnly", true); query.put("direction", "RECEIVABLE"); + query.put("offset", 0); query.put("size", 1); + assertThat(queries.count(query)).isEqualTo(1); + assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(701L); + query.put("storageRentOnly", false); + assertThat(queries.count(query)).isEqualTo(4); + } + + /** 一件代发使用实际订单类型且归属筛选不可能返回另一客户订单。 */ + @Test void orderCandidatesRetainRealTypeAndRejectForeignOwnership() { + jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','DS-01','ONE_CLICK_DROPSHIP','8')"); + jdbc.update("INSERT INTO ll_order VALUES(92,'10002','客户乙','FL-01','FIRST_LEG','8')"); + var options = businesses.options("LASTLEG_ORDER", "10001", "DS-"); + assertThat(options).hasSize(1); assertThat(options.get(0).orderType()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(options.get(0).warehouseId()).isEqualTo(8L); + assertThat(businesses.countOwned("LASTLEG_ORDER", 91, "10002", "DS-01")).isZero(); + assertThat(businesses.countCustomerOwned("LASTLEG_ORDER", 91, "10001")).isEqualTo(1); + } + + /** 换标分类按订单实体及客户识别,分页和总数同步且不凭单号前缀推断。 */ + @Test void relabelClassificationUsesOwnedOrderAndConsistentPageFilter() { + jdbc.execute("ALTER TABLE ll_finance_fee_source ADD business_id BIGINT"); + jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','TB-01','RELABEL_APPLICATION','8')"); + jdbc.update("INSERT INTO ll_order VALUES(92,'10001','客户甲','TB-02','FIRST_LEG','8')"); + jdbc.update("INSERT INTO ll_order VALUES(93,'10002','客户乙','TB-03','RELABEL_APPLICATION','8')"); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_id,business_no,direction) VALUES" + + "(1,701,1,'10001','LASTLEG_ORDER',91,'TB-01','RECEIVABLE')," + + "(2,702,1,'10001','LASTLEG_ORDER',92,'TB-02','RECEIVABLE')," + + "(3,703,1,'10001','LASTLEG_ORDER',93,'TB-03','RECEIVABLE')," + + "(4,704,1,'10001','RELABEL_ORDER',94,'TB-04','RECEIVABLE')"); + assertThat(businesses.relabelFeeIds(java.util.List.of(701L,702L,703L,704L))).containsExactly(701L); + var query = new HashMap(); query.put("customerId", "10001"); + query.put("businessType", "RELABEL_ORDER"); query.put("direction", "RECEIVABLE"); + query.put("offset", 0); query.put("size", 20); + assertThat(queries.count(query)).isEqualTo(2); + assertThat(queries.page(query)).extracting("sourceFeeId").containsExactlyInAnyOrder(701L,704L); + query.put("businessType", "LASTLEG_ORDER"); + assertThat(queries.count(query)).isEqualTo(2); + assertThat(queries.page(query)).extracting("sourceFeeId").containsExactlyInAnyOrder(702L,703L); + } + + /** 新费用定位同客户真实换标申请,不能凭相同单号关联其他客户。 */ + @Test void relabelOrderResolutionRequiresActualOwnedApplication() { + jdbc.execute("ALTER TABLE ll_relabel_application ADD id BIGINT"); + jdbc.execute("ALTER TABLE ll_relabel_application ADD relabel_no VARCHAR(128)"); + jdbc.execute("ALTER TABLE ll_relabel_application ADD lastleg_order_id BIGINT"); + jdbc.update("INSERT INTO ll_order VALUES(91,'10001','客户甲','TB-01','RELABEL_APPLICATION','8')"); + jdbc.update("INSERT INTO ll_relabel_application VALUES('10002',501,'TB-01',91)"); + assertThat(businesses.relabelForOrder(91, "10001")).isNull(); + jdbc.update("INSERT INTO ll_relabel_application VALUES('10001',502,'TB-01',91)"); + var business = businesses.relabelForOrder(91, "10001"); + assertThat(business.businessId()).isEqualTo(502L); + assertThat(business.businessType()).isEqualTo("RELABEL_ORDER"); + assertThat(business.orderId()).isEqualTo(91L); + assertThat(businesses.relabelForOrder(91, "10002")).isNull(); + } + + /** 业务状态过滤在数据库统一分页,覆盖原扣费失败和原事件推送失败。 */ + @Test void businessProcessingFiltersMatchCountsAndPages() { + for (String column : java.util.List.of("source_status VARCHAR(16)", "publication_status VARCHAR(16)", "source_type VARCHAR(64)", "created_by VARCHAR(128)")) + jdbc.execute("ALTER TABLE ll_finance_fee_source ADD " + column); + jdbc.execute("CREATE TABLE ll_event_outbox(event_key VARCHAR(255) PRIMARY KEY,customer_id VARCHAR(128),status VARCHAR(32),last_error VARCHAR(1000))"); + String[] statuses = {"WAITING", "PROCESSING", "COMPLETED", "FAILED", "CLOSED"}; + for (int index = 0; index < statuses.length; index++) { + long source = 701L + index; + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_no,direction,review_status,source_status,publication_status,source_type) VALUES(?,?,1,'10001','LASTLEG_ORDER','TB-1','PAYABLE',?,'ACTIVE',?,'OVERSEAS_ORDER_FEE')", + index + 1, source, index == 4 ? "REJECTED" : index == 0 ? "PENDING" : "APPROVED", index == 0 || index == 4 ? "UNPUBLISHED" : "PUBLISHED"); + if (index > 0 && index < 4) jdbc.update("INSERT INTO ll_event_outbox(event_key,customer_id,status) VALUES(?,'10001',?)", "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_ORDER_FEE:" + source + ":1:PUBLISHED", index == 1 ? "PENDING" : index == 2 ? "PUBLISHED" : "DEAD"); + } + Map query = new HashMap<>(); query.put("direction", "PAYABLE"); query.put("offset", 0); query.put("size", 1); + for (int index = 0; index < statuses.length; index++) { + query.put("processingStatus", statuses[index]); + assertThat(queries.count(query)).isEqualTo(1); + assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(701L + index); + } + // 自动重试已发生真实失败时,失败筛选与页面一致;接收后恢复已完成。 + jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error='S0500' WHERE event_key LIKE '%:704:1:PUBLISHED'"); + query.put("processingStatus", "FAILED"); assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(704L); + jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL WHERE event_key LIKE '%:704:1:PUBLISHED'"); + query.put("processingStatus", "FAILED"); assertThat(queries.count(query)).isZero(); + query.put("processingStatus", "COMPLETED"); assertThat(queries.count(query)).isEqualTo(2); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_no,direction,review_status,source_status,publication_status) VALUES(6,706,1,'10001','WAREHOUSE_TRANSFER','WT-1','RECEIVABLE','APPROVED','ACTIVE','UNPUBLISHED')"); + var state = new OverseasFeeFunding(); state.setSourceFeeId(706L); state.setCustomerId("10001"); state.setCollectionMode("WALLET"); state.setWalletStatus("SETTLE_FAILED"); state.setCommandVersion(3L); state.setCommandHistory(new LinkedHashMap<>()); state.setCreatedAt(LocalDateTime.now()); state.setUpdatedAt(LocalDateTime.now()); funding.insert(state); + query.put("direction", "RECEIVABLE"); query.put("processingStatus", "FAILED"); + assertThat(queries.count(query)).isEqualTo(1); assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(706L); + // 旧费用推送失败不能覆盖已实扣结果,完成筛选和分页必须同步。 + jdbc.update("UPDATE ll_finance_fee_source SET source_type='OVERSEAS_ORDER_FEE',publication_status='PUBLISHED' WHERE source_fee_id=706"); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged:LASTLEG:OVERSEAS_ORDER_FEE:706:1:PUBLISHED','10001','DEAD','旧推送失败')"); + state.setWalletStatus("SETTLED"); funding.updateById(state); + assertThat(queries.count(query)).isZero(); + query.put("processingStatus", "COMPLETED"); + assertThat(queries.count(query)).isEqualTo(1); + assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(706L); + // 驳回不等于资金已释放,失败补偿仍须留在失败筛选,避免被冻结资金隐藏。 + jdbc.update("UPDATE ll_finance_fee_source SET review_status='REJECTED' WHERE source_fee_id=706"); + state.setWalletStatus("RELEASE_FAILED"); funding.updateById(state); + query.put("processingStatus", "FAILED"); + assertThat(queries.count(query)).isEqualTo(1); + assertThat(queries.page(query)).extracting("sourceFeeId").containsExactly(706L); + query.put("processingStatus", "CLOSED"); assertThat(queries.count(query)).isZero(); + state.setWalletStatus("RELEASE_PENDING"); funding.updateById(state); + query.put("processingStatus", "PROCESSING"); assertThat(queries.count(query)).isEqualTo(1); + state.setWalletStatus("RELEASED"); funding.updateById(state); + query.put("processingStatus", "CLOSED"); assertThat(queries.count(query)).isEqualTo(1); + } + + /** 外来货客户即使没有头程订单和历史费用,也必须能新增转运费用。 */ + @Test void externalTransferCustomerIsSelectableBeforeAnyFeeExists() { + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('20001')"); + assertThat(businesses.customers()).extracting("customerId").containsExactly("20001"); + } + /** 同名人工分次费用不拦截,只防止旧自动来源及其公共目录别名再收费。 */ + @Test void onlyOldAutomaticProjectBlocksEquivalentCatalogItem() { + for (String column : java.util.List.of("business_id BIGINT","order_id BIGINT","fee_item_name VARCHAR(100)","source_status VARCHAR(16)")) + jdbc.execute("ALTER TABLE ll_finance_fee_source ADD " + column); + jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,business_type,business_id,business_no,fee_item_code,fee_item_name,direction,review_status,source_status) VALUES(1,701,1,'10001','DELIVERY_ORDER',91,'DL-1','DELIVERY_FEE','派送费','RECEIVABLE','APPROVED','ACTIVE')"); + assertThat(queries.legacyProject("10001","DELIVERY_ORDER",91,"FEE_TYPE_075","派送费",702)).isEqualTo(1); + assertThat(queries.legacyProject("10002","DELIVERY_ORDER",91,"FEE_TYPE_075","派送费",702)).isZero(); + assertThat(queries.legacyProject("10001","DELIVERY_ORDER",91,"FEE_TYPE_071","贴标费",702)).isZero(); + jdbc.update("UPDATE ll_finance_fee_source SET fee_item_code='FEE_TYPE_075'"); + assertThat(queries.legacyProject("10001","DELIVERY_ORDER",91,"FEE_TYPE_075","派送费",702)).isZero(); + } + + /** 费用说明真实落库;重复提醒仅匹配本客户、业务、方向和最新有效费用。 */ + @Test void feeRemarkPersistsAndDuplicateQueryKeepsBusinessScope() { + jdbc.execute("ALTER TABLE ll_finance_fee_source ADD business_id BIGINT"); + jdbc.execute("ALTER TABLE ll_finance_fee_source ADD source_status VARCHAR(16)"); + jdbc.execute("ALTER TABLE ll_finance_fee_source ADD fee_remark VARCHAR(500)"); + var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); + fee.setId(1L); fee.setSourceFeeId(701L); fee.setSourceVersion(1); fee.setCustomerId("10001"); + fee.setBusinessType("WAREHOUSE_TRANSFER"); fee.setBusinessId(91L); fee.setBusinessNo("TR-1"); + fee.setFeeItemCode("HANDLING"); fee.setDirection("RECEIVABLE"); fee.setSourceStatus("ACTIVE"); + fee.setReviewStatus("REJECTED"); fee.setFeeRemark("第二次装卸,另收操作费"); + fees.insert(fee); + assertThat(fees.lockCurrent(701L).getFeeRemark()).isEqualTo("第二次装卸,另收操作费"); + assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 702L)).isEqualTo(1); + assertThat(queries.sameProject("10002", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 702L)).isZero(); + assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "PAYABLE", "HANDLING", 702L)).isZero(); + assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 92L, "RECEIVABLE", "HANDLING", 702L)).isZero(); + assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 701L)).isZero(); + jdbc.update("UPDATE ll_finance_fee_source SET source_status='VOIDED'"); + assertThat(queries.sameProject("10001", "WAREHOUSE_TRANSFER", 91L, "RECEIVABLE", "HANDLING", 702L)).isZero(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeCatalogControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeCatalogControllerTest.java new file mode 100644 index 000000000..1de46a667 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeCatalogControllerTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.financefee.controller.PlatformOverseasFeeCatalogController; +import com.baiyue.lastleg.financefee.service.FeeCatalogGateway; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.util.List; +import java.util.Set; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformOverseasFeeCatalogControllerTest { + /** HTTP 参数默认应收,切换应付时真实透传到目录服务。 */ + @Test void bindsDefaultAndPayableDirection() throws Exception { + FeeCatalogGateway catalog = mock(FeeCatalogGateway.class); + when(catalog.listActive(anyString())).thenReturn(List.of()); + var mvc = MockMvcBuilders.standaloneSetup(new PlatformOverseasFeeCatalogController(catalog)) + .setCustomArgumentResolvers(new org.springframework.web.method.support.HandlerMethodArgumentResolver() { + /** 为只读平台接口提供测试身份。 */ + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType() == AuthenticatedUser.class; + } + /** 构造合法平台主体,不影响查询参数绑定。 */ + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer container, + org.springframework.web.context.request.NativeWebRequest request, + org.springframework.web.bind.support.WebDataBinderFactory binder) { + return new AuthenticatedUser(1L, 0L, 0L, 1L, "tester", "PLATFORM", "WEB", Set.of()); + } + }).build(); + mvc.perform(get("/api/platform/v1/last-leg/finance-fee-sources/fee-items")).andExpect(status().isOk()); + mvc.perform(get("/api/platform/v1/last-leg/finance-fee-sources/fee-items").param("direction", "PAYABLE")) + .andExpect(status().isOk()); + verify(catalog).listActive("RECEIVABLE"); + verify(catalog).listActive("PAYABLE"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/StorageRentZeroFeePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/StorageRentZeroFeePersistenceTest.java new file mode 100644 index 000000000..39c8ec574 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/StorageRentZeroFeePersistenceTest.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.financefee; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; + +class StorageRentZeroFeePersistenceTest { + private OverseasFeeSourceMapper mapper; + private JdbcTemplate jdbc; + /** 使用发布基线原始表定义和真正 Mapper,避免手写简化表遗漏 CHECK。 */ + @BeforeEach void setup() throws Exception { + String source=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + var match=Pattern.compile("CREATE TABLE IF NOT EXISTS ll_finance_fee_source \\((.*?)\\) ENGINE=InnoDB",Pattern.DOTALL).matcher(source); + assertThat(match.find()).isTrue(); + String ddl="CREATE TABLE ll_finance_fee_source ("+match.group(1)+")"; + ddl=ddl.replaceAll("\\bJSON\\b","VARCHAR(10000)") + .replaceAll("UNIQUE KEY (\\w+)\\(","CONSTRAINT $1 UNIQUE(") + .replaceAll("KEY \\w+\\([^)]*\\),?",""); + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(ds);jdbc.execute(ddl); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(OverseasFeeSourceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasFeeSourceMapper.class); + } + /** 免费美元仓租无需虚构汇率,审核与新版本调价必须真实持久化。 */ + @Test void freeRentPersistsWithoutRateAndCanBecomePaidVersion() { + // 模拟已有库默认1:省略NULL字段会把后续USD调价错误固化为1:1。 + jdbc.execute("ALTER TABLE ll_finance_fee_source ALTER COLUMN exchange_rate SET DEFAULT 1.00000000"); + var free=fee(1);assertThat(mapper.insert(free)).isEqualTo(1); + var saved=mapper.selectById(1L);assertThat(saved.getAmount()).isZero();assertThat(saved.getExchangeRate()).isNull(); + assertThat(saved.getReviewStatus()).isEqualTo("PENDING");assertThat(saved.getWalletSettlement()).isNull(); + free.setSourceStatus("SUPERSEDED");mapper.updateById(free); + var paid=fee(2);paid.setSourceFeeId(1L);paid.setSourceVersion(2);paid.setOriginalAmount(new BigDecimal("20")); + paid.setAmount(new BigDecimal("142.47"));paid.setExchangeRate(new BigDecimal("7.1235"));paid.setFeeRemark("调整说明"); + mapper.insert(paid);paid.setReviewStatus("APPROVED");mapper.updateById(paid); + assertThat(mapper.selectById(2L).getAmount()).isEqualByComparingTo("142.47"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_finance_fee_source",Integer.class)).isEqualTo(2); + } + /** 零元例外仅属于自动应收仓租,人工、其他项目、应付和负额仍被数据库拦截。 */ + @Test void zeroExceptionCannotLeakToManualOtherProjectsOrPayables() { + var manual=fee(1);manual.setCreatedBy("admin");assertThatThrownBy(()->mapper.insert(manual)).isInstanceOf(Exception.class); + var other=fee(2);other.setFeeItemCode("OTHER");assertThatThrownBy(()->mapper.insert(other)).isInstanceOf(Exception.class); + var payable=fee(3);payable.setDirection("PAYABLE");assertThatThrownBy(()->mapper.insert(payable)).isInstanceOf(Exception.class); + var negative=fee(4);negative.setAmount(BigDecimal.ONE.negate());assertThatThrownBy(()->mapper.insert(negative)).isInstanceOf(Exception.class); + var unmatched=fee(5);unmatched.setOriginalAmount(BigDecimal.ONE);assertThatThrownBy(()->mapper.insert(unmatched)).isInstanceOf(Exception.class); + } + /** 正额费用必须有效汇率;空值及零汇率不能绕过 SQL 三值逻辑。 */ + @Test void nonzeroFeeRequiresPositiveRate() { + var paid=fee(1);paid.setAmount(BigDecimal.ONE);paid.setOriginalAmount(BigDecimal.ONE); + assertThatThrownBy(()->mapper.insert(paid)).isInstanceOf(Exception.class); + paid.setExchangeRate(BigDecimal.ZERO);assertThatThrownBy(()->mapper.insert(paid)).isInstanceOf(Exception.class); + paid.setExchangeRate(BigDecimal.ONE.negate());assertThatThrownBy(()->mapper.insert(paid)).isInstanceOf(Exception.class); + paid.setExchangeRate(BigDecimal.ONE);assertThat(mapper.insert(paid)).isEqualTo(1); + } + private OverseasFeeSource fee(long id) { + var f=new OverseasFeeSource();f.setId(id);f.setSourceFeeId(id);f.setSourceVersion(1);f.setCustomerId("101"); + f.setSourceType("OVERSEAS_WAREHOUSE_FEE");f.setSourceNo("ZY-TEST");f.setBusinessType("WAREHOUSE_TRANSFER");f.setBusinessNo("ZY-TEST"); + f.setFeeItemCode("FEE_TYPE_077");f.setFeeItemName("仓租");f.setDirection("RECEIVABLE");f.setBusinessDate(LocalDate.of(2026,10,23)); + f.setCurrency("CNY");f.setAmount(BigDecimal.ZERO);f.setOriginalCurrency("USD");f.setOriginalAmount(BigDecimal.ZERO);f.setExchangeRateMonth("2026-10"); + f.setReviewStatus("PENDING");f.setPublicationStatus("UNPUBLISHED");f.setSourceStatus("ACTIVE");f.setCreatedBy("STORAGE_RENT");f.setUpdatedBy("STORAGE_RENT"); + f.setCreatedAt(LocalDateTime.now());f.setUpdatedAt(LocalDateTime.now());return f; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/LegacyRelabelSettledFeeSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/LegacyRelabelSettledFeeSourceTest.java new file mode 100644 index 000000000..a6ca8a96c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/LegacyRelabelSettledFeeSourceTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.WalletSettlementReference; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class LegacyRelabelSettledFeeSourceTest { + private final OverseasFeeSourceMapper mapper=mock(OverseasFeeSourceMapper.class); + private final OverseasFeeOutboxService outbox=mock(OverseasFeeOutboxService.class); + private final OverseasFeeCurrencyConverter converter=mock(OverseasFeeCurrencyConverter.class); + private final OverseasFeeSourceService service=spy(new OverseasFeeSourceService(mapper,mock(OverseasFeeCommandMapper.class), + mock(OverseasFeeBusinessGuard.class),outbox,converter,new ObjectMapper().findAndRegisterModules(),mock(OverseasFeeCancellation.class))); + private final WalletSettlementReference reference=new WalletSettlementReference("command","hold","ledger"); + + @Test void existingPublishedFeeGetsReferenceWithoutSecondSourceOrPublication() { + var prior=new OverseasFeeSource(); prior.setSourceFeeId(701L); prior.setFeeItemCode("RELABEL_SERVICE"); + prior.setDirection("RECEIVABLE"); prior.setSourceStatus("ACTIVE"); prior.setAmount(BigDecimal.TEN); + doReturn(List.of(prior)).when(service).listForBusiness("9","RELABEL_ORDER",11L); + when(mapper.lockCurrent(701L)).thenReturn(prior); + assertThat(service.completeLegacyRelabelSettlement("callback","RELABEL:RL-1",request(),reference)).isSameAs(prior); + assertThat(prior.getWalletSettlement()).isEqualTo(reference); + verify(mapper).updateById(prior); verify(mapper,never()).insert(any(OverseasFeeSource.class)); verifyNoInteractions(outbox); + } + + @Test void unpublishedLegacyHoldRecordsPaidReferenceWithoutFinancePublication() { + doReturn(List.of()).when(service).listForBusiness("9","RELABEL_ORDER",11L); + when(converter.convert(eq("CNY"),eq(BigDecimal.TEN),any())).thenReturn(new OverseasFeeCurrencyConverter.Conversion("CNY",BigDecimal.TEN,BigDecimal.ONE,"2026-10",BigDecimal.TEN)); + var result=service.completeLegacyRelabelSettlement("callback","RELABEL:RL-1",request(),reference); + assertThat(result.getWalletSettlement()).isEqualTo(reference); + verify(mapper).insert(result); verifyNoInteractions(outbox); + } + + private SaveRequest request() { + return new SaveRequest("9","客户","OVERSEAS_RELABEL_FEE","RELABEL_ORDER",11L,"RL-1",12L,null,null,null,null,null,null, + "RELABEL_SERVICE","换标费", null,"RECEIVABLE",null,null,null,null,null,null,null,List.of(),LocalDate.of(2026,10,22), + "CNY",BigDecimal.TEN,BigDecimal.ONE,"BOX",BigDecimal.TEN,null,null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java index 4492db620..1827ce5d1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java @@ -51,7 +51,7 @@ class OverseasContainerFeeSourceServiceTest { SaveRequest request = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(), source.businessType(), source.businessId(), source.businessNo(), source.orderId(), source.inboundId(), source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(), - source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(), + source.warehouseId(), "CUSTOM_FEE", "自定义费", null, source.direction(), source.supplierId(), source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(), @@ -69,4 +69,15 @@ class OverseasContainerFeeSourceServiceTest { verify(mapper).selectList(any()); } + + /** 旧提交入口也必须关闭驳回记录,不能绕过人工费用服务清空审核留痕。 */ + @Test void rejectedLegacySourceCannotBeResubmitted() { + var fee = new OverseasFeeSource(); fee.setSourceFeeId(701L); fee.setSourceType("OVERSEAS_ORDER_FEE"); + fee.setBusinessType("LASTLEG_ORDER"); fee.setReviewStatus("REJECTED"); fee.setReviewReason("金额录错"); + fee.setReviewerName("审核员"); when(mapper.lockCurrent(701L)).thenReturn(fee); + assertThatThrownBy(() -> service.submit(701L, "operator", "legacy-rejected")) + .hasMessageContaining("已驳回费用不能重新提交"); + assertThat(fee.getReviewReason()).isEqualTo("金额录错"); assertThat(fee.getReviewerName()).isEqualTo("审核员"); + org.mockito.Mockito.verify(mapper, org.mockito.Mockito.never()).updateById(any(OverseasFeeSource.class)); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java index df390eb6c..00023e46e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java @@ -1,127 +1,67 @@ package com.baiyue.lastleg.financefee.service; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository; import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; import org.springframework.web.server.ResponseStatusException; - import java.math.BigDecimal; import java.time.LocalDate; -import java.util.ArrayDeque; -import java.util.ArrayList; import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class OverseasFeeBusinessGuardTest { + private final OverseasFeeBusinessRepository repository = mock(OverseasFeeBusinessRepository.class); + private final OverseasFeeBusinessGuard guard = new OverseasFeeBusinessGuard(repository); - @Test - void acceptsOnlyARealContainerOwnedByTheCustomer() { - StubJdbc jdbc = new StubJdbc(3, 0, 1); - new OverseasFeeBusinessGuard(jdbc).requireOwned(containerFee("customer-a", 701L, "MSCU1234567")); - - assertThat(jdbc.sql()).hasSize(3); - assertThat(jdbc.sql().get(0)).contains("source_container_booking_id"); - assertThat(jdbc.sql().get(1)).contains("container_no"); - assertThat(jdbc.sql().get(2)).contains("customer_id"); + @Test void acceptsOnlyARealContainerOwnedByTheCustomer() { + when(repository.container("701")).thenReturn(3); + when(repository.containerCustomer("701", "customer-a")).thenReturn(1); + guard.requireOwned(containerFee("customer-a", 701L, "MSCU1234567")); + verify(repository).inconsistentContainer("701", "MSCU1234567"); + verify(repository).containerCustomer("701", "customer-a"); } - - @Test - void rejectsAContainerThatDoesNotContainTheClaimedCustomer() { - StubJdbc jdbc = new StubJdbc(3, 0, 0); - - assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc) - .requireOwned(containerFee("other-customer", 701L, "MSCU1234567"))) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("该客户不属于费用关联的真实货柜"); + @Test void rejectsAContainerThatDoesNotContainTheClaimedCustomer() { + when(repository.container("701")).thenReturn(3); + assertThatThrownBy(() -> guard.requireOwned(containerFee("other", 701L, "MSCU1234567"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("该客户不属于费用关联的真实货柜"); } - - @Test - void rejectsAContainerWhoseOrdersDoNotShareTheClaimedContainerNumber() { - StubJdbc jdbc = new StubJdbc(3, 1); - - assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc) - .requireOwned(containerFee("customer-a", 701L, "MSCU1234567"))) - .isInstanceOf(ResponseStatusException.class) + @Test void rejectsInconsistentContainerBeforeCustomerLookup() { + when(repository.container("701")).thenReturn(3); + when(repository.inconsistentContainer("701", "MSCU1234567")).thenReturn(1); + assertThatThrownBy(() -> guard.requireOwned(containerFee("customer-a", 701L, "MSCU1234567"))) .hasMessageContaining("真实货柜的集装箱号缺失或不一致"); + verify(repository, never()).containerCustomer(anyString(), anyString()); } - - @Test - void rejectsDifferentBusinessAndContainerBookingIdentifiersBeforeQuerying() { + @Test void rejectsDifferentBusinessAndContainerBookingIdentifiersBeforeQuerying() { SaveRequest source = containerFee("customer-a", 701L, "MSCU1234567"); SaveRequest inconsistent = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(), source.businessType(), 702L, source.businessNo(), source.orderId(), source.inboundId(), source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(), - source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(), + source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.feeRemark(), source.direction(), source.supplierId(), source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(), source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd()); - StubJdbc jdbc = new StubJdbc(); - - assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("真实来源柜ID"); - assertThat(jdbc.sql()).isEmpty(); + assertThatThrownBy(() -> guard.requireOwned(inconsistent)).hasMessageContaining("真实来源柜ID"); + verifyNoInteractions(repository); } - - @Test - void listsAllContainerCustomersWithOneGroupedQuery() { - StubJdbc jdbc = new StubJdbc(); - jdbc.queryResults = List.of( - new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-a", "客户A"), - new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-b", "客户B")); - - var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L); - - assertThat(result).extracting("customerId").containsExactly("customer-a", "customer-b"); - assertThat(jdbc.sql()).singleElement().asString().contains("GROUP BY customer_id"); + @Test void listsAllContainerCustomersInOneRepositoryCall() { + when(repository.containerCustomers("701")).thenReturn(List.of(new ContainerCustomerOption("100", "客户甲"))); + assertThat(guard.listContainerCustomers(701L)).extracting("customerId").containsExactly("100"); + verify(repository).containerCustomers("701"); } - - @Test - void returnsEmptyWhenContainerHasNoLastlegOrders() { - StubJdbc jdbc = new StubJdbc(); - - var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L); - - assertThat(result).isEmpty(); - assertThat(jdbc.sql()).singleElement().asString().contains("source_container_booking_id"); + @Test void returnsEmptyForAnUnknownContainer() { + assertThat(guard.listContainerCustomers(701L)).isEmpty(); } static SaveRequest containerFee(String customerId, Long bookingId, String containerNo) { return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER", bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L, - "PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null, + "PORT_FEE", "港口费", null, "RECEIVABLE", null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"), null, null, null, null, null); } - private static final class StubJdbc extends JdbcTemplate { - private final ArrayDeque results = new ArrayDeque<>(); - private final List sql = new ArrayList<>(); - private List queryResults = List.of(); - - private StubJdbc(Integer... values) { - results.addAll(List.of(values)); - } - - @Override - public T queryForObject(String statement, Class requiredType, Object... args) { - sql.add(statement); - return requiredType.cast(results.removeFirst()); - } - - @Override - @SuppressWarnings("unchecked") - public List query(String statement, RowMapper rowMapper, Object... args) { - sql.add(statement); - return (List) queryResults; - } - - private List sql() { - return sql; - } - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNamesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNamesTest.java new file mode 100644 index 000000000..519607815 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCustomerNamesTest.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.lastleg.financefee.client.OverseasFeeCustomerClient; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OverseasFeeCustomerNamesTest { + private final OverseasFeeCustomerClient client = mock(OverseasFeeCustomerClient.class); + private final OverseasFeeCustomerNames names = new OverseasFeeCustomerNames(client, "token"); + + /** 空快照同客户只请求一次,长整型客户 ID 不经过浮点转换。 */ + @Test void resolvesDistinctCustomersByExactId() { + String id = "9223372036854775002"; + when(client.customers("token", 1, 1, Long.parseLong(id))).thenReturn(ApiResponse.success( + new PageResult<>(List.of(new OverseasFeeCustomerClient.Customer(Long.parseLong(id), " 柏悦客户 ")), + 1, 1, 1, 1))); + assertThat(names.resolve(List.of(customer(id, null), customer(id, " ")))) + .containsExactly(entry(id, "柏悦客户")); + verify(client, times(1)).customers("token", 1, 1, Long.parseLong(id)); + verifyNoMoreInteractions(client); + } + + /** 有旧名称也必须按精确客户 ID 查主档,混合列表和单条详情不能展示不同客户名称。 */ + @Test void currentMasterNameOverridesSnapshotsRegardlessOfPageComposition() { + when(client.customers("token", 1, 1, 10001L)).thenReturn(ApiResponse.success( + new PageResult<>(List.of(new OverseasFeeCustomerClient.Customer(10001L, "客户-66")), 1, 1, 1, 1))); + assertThat(names.resolve(List.of(customer("10001", "旧客户-91"), customer("10001", null), customer("10001", "客户-66")))) + .containsExactly(entry("10001", "客户-66")); + assertThat(names.resolve(List.of(customer("10001", null)))).containsExactly(entry("10001", "客户-66")); + verify(client, times(2)).customers("token", 1, 1, 10001L); + verifyNoMoreInteractions(client); + } + + /** 远端返回其他客户时不得错配名称,失效引用不伪造客户名。 */ + @Test void neverUsesAnotherCustomersName() { + when(client.customers("token", 1, 1, 10001L)).thenReturn(ApiResponse.success( + new PageResult<>(List.of(new OverseasFeeCustomerClient.Customer(10002L, "其他客户")), 1, 1, 1, 1))); + assertThat(names.resolve(List.of(customer("10001", null)))).isEmpty(); + } + + /** 内部服务故障时保留可用快照,避免逐客户放大超时或阻断费用查询。 */ + @Test void stopsRemoteLookupsOnFailureAndRetriesOnNextRead() { + when(client.customers("token", 1, 1, 10001L)) + .thenThrow(new IllegalStateException("暂不可用")) + .thenReturn(ApiResponse.success(new PageResult<>( + List.of(new OverseasFeeCustomerClient.Customer(10001L, "恢复后名称")), 1, 1, 1, 1))); + var customers = List.of(customer("10001", null), customer("10002", null), customer("10003", "已有名称")); + assertThat(names.resolve(customers)).containsExactly(entry("10003", "已有名称")); + verify(client, never()).customers("token", 1, 1, 10002L); + assertThat(names.resolve(List.of(customer("10001", null)))).containsEntry("10001", "恢复后名称"); + } + + /** 业务失败响应不能作为有效主档,后续查询允许恢复。 */ + @Test void rejectedOrEmptyResponsesDoNotProduceNames() { + when(client.customers("token", 1, 1, 10001L)).thenReturn( + ApiResponse.failure(ResultCode.FORBIDDEN, "拒绝", null, null), ApiResponse.success(null)); + assertThat(names.resolve(List.of(customer("10001", null)))).isEmpty(); + assertThat(names.resolve(List.of(customer("10001", null)))).isEmpty(); + } + + /** 非法客户引用及缺失服务身份不发送匿名请求。 */ + @Test void skipsInvalidIdsAndMissingToken() { + assertThat(names.resolve(List.of(customer("invalid", null), customer("0", null), customer(null, null)))) + .isEmpty(); + assertThat(new OverseasFeeCustomerNames(client, "").resolve(List.of(customer("10001", null)))).isEmpty(); + verifyNoInteractions(client); + } + + /** 构造只有客户身份与名称的查询快照。 */ + private static ContainerCustomerOption customer(String id, String name) { + return new ContainerCustomerOption(id, name); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryServiceTest.java new file mode 100644 index 000000000..0f6d97245 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeDeliveryServiceTest.java @@ -0,0 +1,318 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.*; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.RetryDeliveryRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeRetryMapper; +import com.baiyue.lastleg.financefee.repository.OverseasFeeDeliveryRepository; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.transaction.support.TransactionTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasFeeDeliveryServiceTest { + private final LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + private final OverseasManualFeeRepository fees = mock(OverseasManualFeeRepository.class); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private JdbcTemplate jdbc; + private OverseasFeeDeliveryService service; + private OverseasFeeSource fee; + private LastlegEventOutbox row; + private String eventId; + private TransactionTemplate transaction; + + /** 使用隔离内存库验证真实行锁、审计唯一约束及不变事件重投。 */ + @BeforeEach void setup() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),event_key VARCHAR(255) UNIQUE,event_type VARCHAR(128),aggregate_id VARCHAR(128),aggregate_type VARCHAR(128),payload_json TEXT,status VARCHAR(32),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(128),locked_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(64),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(128),payload_hash VARCHAR(80),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_wallet_event_inbox(command_event_id VARCHAR(64),status VARCHAR(16),payload_json TEXT,processed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_storage_rent_preparation(id BIGINT PRIMARY KEY,source_fee_id BIGINT,customer_id VARCHAR(128))"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36),event_id VARCHAR(64),outcome VARCHAR(32),started_at TIMESTAMP)"); + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + configuration.setDefaultScriptingLanguage(H2BinaryLanguageDriver.class); + configuration.addMapper(OverseasFeeRetryMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); factory.afterPropertiesSet(); + var session = new SqlSessionTemplate(factory.getObject()); + service = new OverseasFeeDeliveryService(new OverseasFeeDeliveryRepository(mapper, session.getMapper(OverseasFeeRetryMapper.class)), fees, json); + transaction = new TransactionTemplate(new DataSourceTransactionManager(ds)); + fee = new OverseasFeeSource(); fee.setSourceFeeId(701L); fee.setSourceVersion(1); fee.setCustomerId("10001"); + fee.setSourceType("OVERSEAS_ORDER_FEE"); fee.setDirection("PAYABLE"); fee.setSourceStatus("ACTIVE"); + fee.setReviewStatus("APPROVED"); fee.setPublicationStatus("PUBLISHED"); fee.setAllowedActions(List.of()); + eventId = UUID.randomUUID().toString(); + String key = EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:OVERSEAS_ORDER_FEE:701:1:PUBLISHED"; + var payload = json.createObjectNode().put("sourceSystem", "LASTLEG").put("sourceType", fee.getSourceType()) + .put("sourceFeeId", "701").put("sourceVersion", 1).put("customerId", "10001"); + saveEvent(key, EventTypes.BUSINESS_FEE_SOURCE_CHANGED, fee.getSourceType(), "701", payload); + when(fees.lockFee(701L)).thenReturn(fee); when(fees.funding(anyCollection())).thenReturn(List.of()); + when(fees.current(701L)).thenReturn(fee); + when(mapper.findByEventKey(anyString())).thenAnswer(call -> row.getEventKey().equals(call.getArgument(0)) ? currentRow() : null); + when(mapper.selectList(any())).thenAnswer(call -> List.of(currentRow())); + } + + /** 同一事件的并发重试只产生一条审计,载荷、事件编号保持原值。 */ + @Test void concurrentDuplicateRetryPreservesEventAndCreatesOneAudit() throws Exception { + var request = request(); + String original = row.getPayloadJson(); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> retry(request)); + var second = pool.submit(() -> retry(request)); + first.get(10, TimeUnit.SECONDS); second.get(10, TimeUnit.SECONDS); + } finally { pool.shutdownNow(); } + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class)).isEqualTo(original); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PENDING"); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件"); + verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any()); + } + + /** 已成功、正在发送、旧版本、串事件或跨费用请求全部不能产生第二笔收费。 */ + @Test void refusesSuccessfulPendingStaleOrUnrelatedRetries() { + for (String status : List.of("PUBLISHED", "PROCESSING", "PENDING")) { + jdbc.update("UPDATE ll_event_outbox SET status=?,last_error=NULL", status); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件"); + } + jdbc.update("UPDATE ll_event_outbox SET status='DEAD'"); + assertThatThrownBy(() -> retry(new RetryDeliveryRequest(2, eventId, UUID.randomUUID().toString()))).hasMessageContaining("版本"); + assertThatThrownBy(() -> retry(new RetryDeliveryRequest(1, "other-event", UUID.randomUUID().toString()))).hasMessageContaining("投递记录已变化"); + fee.setSourceFeeId(702L); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("没有可重试"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero(); + } + + /** 列表只对真实失败且摘要完整的当前事件开放重试。 */ + @Test void describesFailureAndDisablesCorruptOrRejectedEvents() { + service.describe(List.of(fee)); + assertThat(fee.getDeliveryStatus()).isEqualTo("DEAD"); assertThat(fee.getAllowedActions()).contains("RETRY_PUBLISH"); + fee.setAllowedActions(List.of()); jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error=NULL"); + service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).isEmpty(); + jdbc.update("UPDATE ll_event_outbox SET status='DEAD',payload_json=REPLACE(payload_json,'10001','10002')"); + service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).isEmpty(); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("不一致"); + fee.setReviewStatus("REJECTED"); service.describe(List.of(fee)); assertThat(fee.getDeliveryStatus()).isNull(); + } + + /** 每次失败后都能重新推送同一事件;同键重放和未失败的待发送事件不能重复排队。 */ + @Test void failedRetryCanBeRetriedAgainBeforeAutomaticBackoffExpires() { + String original = row.getPayloadJson(); + retry(request()); + jdbc.update("UPDATE ll_event_outbox SET status='PENDING',retry_count=1,last_error='财务暂不可用',next_retry_at=DATEADD('HOUR',1,CURRENT_TIMESTAMP)"); + service.describe(List.of(fee)); + assertThat(fee.getDeliveryStatus()).isEqualTo("PENDING"); + assertThat(fee.getAllowedActions()).contains("RETRY_PUBLISH"); + var second = request(); retry(second); retry(second); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class)).isEqualTo(original); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isZero(); + service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).doesNotContain("RETRY_PUBLISH"); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件"); + jdbc.update("UPDATE ll_event_outbox SET status='PROCESSING',last_error='旧失败'"); + service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).doesNotContain("RETRY_PUBLISH"); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("失败事件"); + verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any()); + } + + /** 已实扣应收不受旧费用推送失败影响,禁止再次投递至账单结算。 */ + @Test void collectedReceivableIgnoresOldPublicationFailureAndCannotRepublish() { + retry(request()); + fee.setDirection("RECEIVABLE"); + fee.setWalletSettlement(new WalletSettlementReference("settled-command", "hold", "ledger")); + fee.setAllowedActions(List.of("RETRY_PUBLISH")); + service.describe(List.of(fee)); + assertThat(fee.getDeliveryStatus()).isNull(); + assertThat(fee.getAllowedActions()).isEmpty(); + assertThat(service.history(701L)).isEmpty(); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("没有可重试"); + verify(fees, never()).updateFunding(any()); + } + + /** 资金指令发送失败重投原指令,不能再次创建冻结或结算指令。 */ + @Test void retriesOriginalWalletEventWithoutChangingFunding() throws Exception { + fee.setDirection("RECEIVABLE"); fee.setPublicationStatus("UNPUBLISHED"); + var funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCollectionMode("WALLET"); + funding.setWalletStatus("SETTLE_PENDING"); funding.setCommandVersion(3L); funding.setCommandEventId(eventId); funding.setWalletBusinessNo("701-1"); + when(fees.lockFunding(701L)).thenReturn(funding); when(fees.funding(anyCollection())).thenReturn(List.of(funding)); + var payload = json.createObjectNode().put("customerId", 10001).put("businessType", "OVERSEAS_MANUAL_FEE").put("businessNo", "701-1").put("operation", "SETTLE"); + jdbc.execute("DELETE FROM ll_event_outbox"); + saveEvent("WALLET_COMMAND:10001:OVERSEAS_MANUAL_FEE:701-1:SETTLE:3", EventTypes.WALLET_COMMAND_REQUESTED, "OVERSEAS_MANUAL_FEE", "701-1", payload); + service.describe(List.of(fee)); assertThat(fee.getAllowedActions()).contains("RETRY_FUNDING_DELIVERY"); + retry(request()); + assertThat(funding.getCommandEventId()).isEqualTo(eventId); assertThat(funding.getCommandVersion()).isEqualTo(3L); + verify(fees, never()).updateFunding(any()); + } + + /** 已终止费用仅重投原冻结或释放补偿指令,不能恢复扣款。 */ + @Test void terminatedFeesCanRecoverOriginalFreezeAndReleaseButNeverSettlement() throws Exception { + fee.setDirection("RECEIVABLE"); fee.setPublicationStatus("UNPUBLISHED"); + var funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCollectionMode("WALLET"); + funding.setCommandVersion(3L); funding.setCommandEventId(eventId); funding.setWalletBusinessNo("701-1"); + when(fees.lockFunding(701L)).thenReturn(funding); when(fees.funding(anyCollection())).thenReturn(List.of(funding)); + for (String terminal : List.of("REJECTED", "VOIDED")) { + fee.setReviewStatus("REJECTED".equals(terminal) ? "REJECTED" : "PENDING"); + fee.setSourceStatus("VOIDED".equals(terminal) ? "VOIDED" : "ACTIVE"); + for (String operation : List.of("FREEZE", "RELEASE", "SETTLE")) { + funding.setWalletStatus(operation + "_PENDING"); + jdbc.execute("DELETE FROM ll_event_outbox"); + var payload = json.createObjectNode().put("customerId",10001).put("businessType","OVERSEAS_MANUAL_FEE") + .put("businessNo","701-1").put("operation",operation); + saveEvent("WALLET_COMMAND:10001:OVERSEAS_MANUAL_FEE:701-1:" + operation + ":3", + EventTypes.WALLET_COMMAND_REQUESTED,"OVERSEAS_MANUAL_FEE","701-1",payload); + service.describe(List.of(fee)); + if ("SETTLE".equals(operation)) { + assertThat(fee.getAllowedActions()).doesNotContain("RETRY_FUNDING_DELIVERY"); + assertThatThrownBy(() -> retry(request())).hasMessageContaining("状态已变化"); + } else { + assertThat(fee.getAllowedActions()).contains("RETRY_FUNDING_DELIVERY"); + String original = row.getPayloadJson(); + retry(request()); + assertThat(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox",String.class)).isEqualTo(original); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox",String.class)).isEqualTo("PENDING"); + } + } + } + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit",Integer.class)).isEqualTo(4); + verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any()); + } + + /** 仓租实额结算重投原资金指令,历史按原预占关联,不能误用人工费用身份。 */ + @Test void rentRetriesActualSettlementWithOriginalIdentityAndHistory() throws Exception { + fee.setCreatedBy("STORAGE_RENT");fee.setFeeItemCode("FEE_TYPE_077");fee.setDirection("RECEIVABLE");fee.setPublicationStatus("UNPUBLISHED"); + var funding=new OverseasFeeFunding();funding.setSourceFeeId(701L);funding.setCollectionMode("WALLET");funding.setWalletStatus("SETTLE_PENDING"); + funding.setCommandVersion(3L);funding.setCommandEventId(eventId);funding.setWalletBusinessNo("8031"); + when(fees.lockFunding(701L)).thenReturn(funding);when(fees.funding(anyCollection())).thenReturn(List.of(funding)); + var payload=json.createObjectNode().put("customerId",10001).put("businessType","STORAGE_RENT").put("businessNo","8031").put("operation","SETTLE_ACTUAL"); + jdbc.execute("DELETE FROM ll_event_outbox");jdbc.update("INSERT INTO ll_storage_rent_preparation VALUES(8031,701,'10001')"); + saveEvent("WALLET_COMMAND:10001:STORAGE_RENT:8031:SETTLE_ACTUAL:3",EventTypes.WALLET_COMMAND_REQUESTED,"STORAGE_RENT","8031",payload); + service.describe(List.of(fee));assertThat(fee.getDeliveryKind()).isEqualTo("FUNDING");assertThat(fee.getAllowedActions()).contains("RETRY_FUNDING_DELIVERY"); + retry(request());assertThat(service.history(701L)).hasSize(1);verify(fees,never()).updateFunding(any()); + fee.setCustomerId("10002");assertThat(service.history(701L)).isEmpty(); + } + + /** 保留每次重试时间、人员和当次结果;后续成功不能覆盖上次失败。 */ + @Test void historyRetainsEachRetryOutcomeAndScopesCustomer() { + var first = request(); retry(first); + var firstAt = java.time.LocalDateTime.now().minusMinutes(3).withNano(0); + jdbc.update("UPDATE event_delivery_retry_audit SET requested_at=? WHERE request_id=?", firstAt, first.idempotencyKey()); + jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)", UUID.randomUUID().toString(),eventId,"DEAD",firstAt.plusSeconds(1)); + jdbc.update("UPDATE ll_event_outbox SET status='DEAD',last_error='第一次连接失败'"); + var second = request(); retry(second); + var secondAt = firstAt.plusMinutes(1); + jdbc.update("UPDATE event_delivery_retry_audit SET requested_at=?,operator_name='李四 [2]' WHERE request_id=?",secondAt,second.idempotencyKey()); + jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)",UUID.randomUUID().toString(),eventId,"APPLIED",secondAt.plusSeconds(1)); + jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL"); + var history = service.history(701L); + assertThat(history).hasSize(2); assertThat(history.get(0).result()).isEqualTo("SUCCESS"); + assertThat(history.get(0).operatorName()).isEqualTo("李四 [2]"); assertThat(history.get(0).requestedAt()).isEqualTo(secondAt); + assertThat(history.get(1).result()).isEqualTo("FAILED"); assertThat(history.get(1).errorMessage()).isEqualTo("第一次连接失败"); + fee.setCustomerId("10002"); assertThat(service.history(701L)).isEmpty(); + } + + /** 自动重试每次失败可见,最终接收后才变为成功,并且不重新收费。 */ + @Test void historyShowsFailedAutomaticAttemptThenActualAcknowledgement() { + retry(request()); + assertThat(service.history(701L).get(0).result()).isEqualTo("SUBMITTED"); + var requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", java.time.LocalDateTime.class); + jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)", UUID.randomUUID().toString(), eventId, "RETRY", requested.plusSeconds(1)); + jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error='Consumer rejected event: code=S0500'"); + var failed = service.history(701L).get(0); + assertThat(failed.result()).isEqualTo("FAILED"); assertThat(failed.errorMessage()).contains("S0500"); + jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,?)", UUID.randomUUID().toString(), eventId, "APPLIED", requested.plusSeconds(2)); + jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL"); + var succeeded = service.history(701L).get(0); + assertThat(succeeded.result()).isEqualTo("SUCCESS"); assertThat(succeeded.errorMessage()).isNull(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + verify(fees, never()).updateFee(any()); verify(fees, never()).updateFunding(any()); + } + + /** 没有投递日志时,仍可由本轮重试后的实际错误识别失败。 */ + @Test void historyShowsCurrentErrorWhileAutomaticRetryRemainsPending() { + retry(request()); + jdbc.update("UPDATE ll_event_outbox SET status='PENDING',last_error='S0500'"); + assertThat(service.history(701L).get(0).result()).isEqualTo("FAILED"); + } + + /** 钱包发送成功只算提交,必须读取资金回执才能显示扣款成功或失败。 */ + @Test void fundingHistoryUsesActualWalletResult() throws Exception { + fee.setDirection("RECEIVABLE"); fee.setPublicationStatus("UNPUBLISHED"); + var funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCollectionMode("WALLET"); + funding.setWalletStatus("SETTLE_PENDING"); funding.setCommandVersion(3L); funding.setCommandEventId(eventId); funding.setWalletBusinessNo("701-1"); + var payload = json.createObjectNode().put("customerId",10001).put("businessType","OVERSEAS_MANUAL_FEE").put("businessNo","701-1").put("operation","SETTLE"); + jdbc.execute("DELETE FROM ll_event_outbox"); + saveEvent("WALLET_COMMAND:10001:OVERSEAS_MANUAL_FEE:701-1:SETTLE:3",EventTypes.WALLET_COMMAND_REQUESTED,"OVERSEAS_MANUAL_FEE","701-1",payload); + transaction.executeWithoutResult(status -> service.recordFundingRetry(fee,funding,"张三 [1]","retry-key")); + jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED'"); + assertThat(service.history(701L).get(0).result()).isEqualTo("SUBMITTED"); + var completed = json.createObjectNode().put("commandEventId",eventId).put("customerId",10001).put("result","FAILED").put("errorMessage","可用额度不足"); + var envelope = json.createObjectNode().set("payload",completed); + jdbc.update("INSERT INTO ll_wallet_event_inbox VALUES(?,'PROCESSED',?,CURRENT_TIMESTAMP)",eventId,json.writeValueAsString(envelope)); + var actual = service.history(701L).get(0); + assertThat(actual.operatorName()).isEqualTo("张三 [1]"); assertThat(actual.action()).isEqualTo("重试扣费"); + assertThat(actual.result()).isEqualTo("FAILED"); assertThat(actual.errorMessage()).isEqualTo("可用额度不足"); + } + + /** 标识必须完整匹配,不能将近似事件、其他生产表或带后缀的记录号归入本费用。 */ + @Test void historyRequiresExactEntryAndEventIdentity() { + retry(request()); + jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED',last_error=NULL"); + jdbc.update("INSERT INTO event_delivery_attempt VALUES(?,?,?,CURRENT_TIMESTAMP)", + UUID.randomUUID().toString(),eventId + "-other","DEAD"); + jdbc.update("INSERT INTO ll_wallet_event_inbox VALUES(?,'PROCESSED',?,CURRENT_TIMESTAMP)", + eventId + "-other","{}"); + assertThat(service.history(701L)).singleElement().satisfies(record -> { + assertThat(record.result()).isEqualTo("SUBMITTED"); + assertThat(record.errorMessage()).isNull(); + }); + jdbc.update("UPDATE event_delivery_retry_audit SET entry_id='901-other'"); + assertThat(service.history(701L)).isEmpty(); + jdbc.update("UPDATE event_delivery_retry_audit SET entry_id='901',producer_table='other_outbox'"); + assertThat(service.history(701L)).isEmpty(); + } + + /** H2的无长度BINARY默认截为一字节,使用等价变长二进制保留MySQL的完整标识比较语义。 */ + public static class H2BinaryLanguageDriver extends org.apache.ibatis.scripting.xmltags.XMLLanguageDriver { + /** 仅替换内存库类型名称,不改生产查询的关联、筛选或参数。 */ + @Override public org.apache.ibatis.mapping.SqlSource createSqlSource(org.apache.ibatis.session.Configuration configuration, + String script, Class parameterType) { + return super.createSqlSource(configuration, script.replace(" AS BINARY)", " AS VARBINARY)"), parameterType); + } + } + + private RetryDeliveryRequest request() { return new RetryDeliveryRequest(1, eventId, UUID.randomUUID().toString()); } + private void retry(RetryDeliveryRequest request) { transaction.executeWithoutResult(status -> service.retry(701L, "operator", request)); } + /** 模拟列表读取当前投递状态及错误,避免复用上次失败快照。 */ + private LastlegEventOutbox currentRow() { + row.setStatus(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)); + row.setLastError(jdbc.queryForObject("SELECT last_error FROM ll_event_outbox", String.class)); + row.setPayloadJson(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class)); + return row; + } + private void saveEvent(String key, String type, String aggregateType, String aggregateId, com.fasterxml.jackson.databind.JsonNode payload) throws Exception { + var event = new EventEnvelope(eventId, type, 1, Instant.now(), "10001", aggregateType, aggregateId, EventTypes.WALLET_COMMAND_REQUESTED.equals(type) ? 3L : 1L, null, + UUID.randomUUID().toString(), key, null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + row = new LastlegEventOutbox(); row.setId(901L); row.setCustomerId("10001"); row.setEventType(type); row.setEventKey(key); + row.setStatus("DEAD"); row.setPayloadJson(json.writeValueAsString(event)); row.setLastError("连接失败"); + jdbc.update("INSERT INTO ll_event_outbox(id,customer_id,event_key,event_type,aggregate_id,aggregate_type,payload_json,status,retry_count,last_error) VALUES(901,'10001',?,?,?,?,?,'DEAD',12,'连接失败')", key,type,aggregateId,aggregateType,row.getPayloadJson()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java new file mode 100644 index 000000000..addce77ef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeServiceTest.java @@ -0,0 +1,513 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.BusinessOption; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeFunding; +import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.repository.OverseasFeeBusinessRepository; +import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasManualFeeServiceTest { + private final OverseasFeeSourceService sources = mock(OverseasFeeSourceService.class); + private final OverseasManualFeeRepository repository = mock(OverseasManualFeeRepository.class); + private final OverseasFeeBusinessRepository businesses = mock(OverseasFeeBusinessRepository.class); + private final FeeCatalogGateway catalog = mock(FeeCatalogGateway.class); + private final WalletCommandOutboxService wallet = mock(WalletCommandOutboxService.class); + private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class); + private final OverseasFeeCustomerNames customerNames = mock(OverseasFeeCustomerNames.class); + private final OverseasManualFeeService service = new OverseasManualFeeService(sources, repository, businesses, + catalog, wallet, outbox, new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class), customerNames, mock(OverseasFeeDeliveryService.class)); + private OverseasFeeSource fee; + private OverseasFeeFunding funding; + + /** 列表与详情均按客户 ID 补齐名称,查询不修改费用或资金记录。 */ + @Test void fillsMissingCustomerNameForPageAndDetail() { + when(customerNames.resolve(anyCollection())).thenReturn(java.util.Map.of("10001", "柏悦客户")); + when(repository.page(anyMap())).thenReturn(List.of(fee)); + when(repository.count(anyMap())).thenReturn(1L); + PageResult page = service.page(1, 10, null, null, null, null, null, null, null, null); + assertThat(page.records().get(0).getCustomerName()).isEqualTo("柏悦客户"); + fee.setCustomerName(" "); + assertThat(service.detail(701L).getCustomerName()).isEqualTo("柏悦客户"); + verify(repository, never()).updateFee(any()); + verify(repository, never()).updateFunding(any()); + verifyNoInteractions(wallet, outbox); + } + + /** 已有换标派送费只补显示分类,原收费身份、金额及资金状态保持原值。 */ + @Test void relabelDeliveryDisplaysRealTypeWithoutRewritingFinancialIdentity() { + fee.setBusinessType("LASTLEG_ORDER"); fee.setBusinessNo("TB202610220001"); + fee.setFeeItemName("派送费"); fee.setAmount(new BigDecimal("66.70")); + when(businesses.relabelFeeIds(List.of(701L))).thenReturn(List.of(701L)); + when(repository.page(anyMap())).thenReturn(List.of(fee)); when(repository.count(anyMap())).thenReturn(1L); + var row = service.page(1, 20, "10001", "RELABEL_ORDER", null, null, null, null, null, null).records().get(0); + assertThat(row.getDisplayBusinessType()).isEqualTo("RELABEL_ORDER"); + assertThat(service.detail(701L).getDisplayBusinessType()).isEqualTo("RELABEL_ORDER"); + assertThat(row.getBusinessType()).isEqualTo("LASTLEG_ORDER"); + assertThat(row.getBusinessId()).isEqualTo(91L); assertThat(row.getBusinessNo()).isEqualTo("TB202610220001"); + assertThat(row.getFeeItemName()).isEqualTo("派送费"); assertThat(row.getAmount()).isEqualByComparingTo("66.70"); + assertThat(row.getWalletStatus()).isEqualTo("FROZEN"); + verify(repository, never()).updateFee(any()); verify(repository, never()).updateFunding(any()); + verifyNoInteractions(sources, wallet, outbox); + } + + /** 换标履约入口登记派送费时使用真实申请身份,同时保留履约订单及收费内容。 */ + @Test void relabelDeliveryCreationUsesApplicationAndReplaysSingleFreeze() throws Exception { + var request = relabelDeliveryRequest(); + when(businesses.orderType(91L, "10001")).thenReturn("RELABEL_APPLICATION"); + when(businesses.owned("LASTLEG_ORDER", 91L, "10001", "TB202610220001")).thenReturn(1); + when(businesses.relabelForOrder(91L, "10001")).thenReturn( + new BusinessOption(501L, "RELABEL_ORDER", "TB202610220001", "10001", "客户甲", 8L, 91L, "RELABEL_APPLICATION")); + fee.setBusinessType("RELABEL_ORDER"); fee.setBusinessId(501L); + fee.setFeeItemName("派送费"); fee.setAmount(new BigDecimal("66.70")); + fee.setOriginalAmount(new BigDecimal("66.70")); + when(sources.create(eq("operator"), eq("relabel-delivery"), any())).thenReturn(fee); + when(repository.lockFunding(701L)).thenReturn(null, funding); + service.create("operator", "relabel-delivery", request); + service.create("operator", "relabel-delivery", request); + var captured = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); + verify(sources, times(2)).create(eq("operator"), eq("relabel-delivery"), captured.capture()); + var saved = captured.getValue(); + assertThat(saved.sourceType()).isEqualTo("OVERSEAS_RELABEL_FEE"); + assertThat(saved.businessType()).isEqualTo("RELABEL_ORDER"); assertThat(saved.businessId()).isEqualTo(501L); + assertThat(saved.orderId()).isEqualTo(91L); assertThat(saved.businessNo()).isEqualTo("TB202610220001"); + assertThat(saved.feeItemName()).isEqualTo("派送费"); assertThat(saved.amount()).isEqualByComparingTo("66.70"); + verify(wallet, times(1)).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), eq(new BigDecimal("66.70")), anyString(), eq(1L)); + } + + /** 错误单号或缺失真实换标申请时,不能创建费用或发起资金指令。 */ + @Test void relabelDeliveryRejectsMismatchedOrMissingBusiness() throws Exception { + var request = relabelDeliveryRequest(); + when(businesses.orderType(91L, "10001")).thenReturn("RELABEL_APPLICATION"); + assertThatThrownBy(() -> service.create("operator", "bad", request)).hasMessageContaining("不一致"); + when(businesses.owned("LASTLEG_ORDER", 91L, "10001", "TB202610220001")).thenReturn(1); + assertThatThrownBy(() -> service.create("operator", "missing", request)).hasMessageContaining("真实换标申请"); + verifyNoInteractions(sources, wallet, outbox); + } + + /** 复用完整费用请求快照,仅替换本场景业务与派送项目。 */ + private com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest relabelDeliveryRequest() throws Exception { + var mapper = new ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode data = mapper.valueToTree( + OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567")); + data.put("sourceType", "OVERSEAS_ORDER_FEE"); data.put("businessType", "LASTLEG_ORDER"); + data.put("businessId", 91L); data.put("businessNo", "TB202610220001"); data.put("orderId", 91L); + data.put("feeItemCode", "FEE_TYPE_075"); data.put("feeItemName", "派送费"); data.put("amount", new BigDecimal("66.70")); + return mapper.treeToValue(data, com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); + } + + /** 履约详情只读取同客户真实申请的费用,客户不匹配时不得继续读取。 */ + @Test void relabelFulfillmentReadsOnlyItsOwnedApplicationFees() { + when(businesses.relabelForOrder(91L, "10001")).thenReturn( + new BusinessOption(501L, "RELABEL_ORDER", "TB-01", "10001", "客户甲", 8L, 91L, "RELABEL_APPLICATION")); + when(sources.listForBusiness("10001", "RELABEL_ORDER", 501L)).thenReturn(List.of(fee)); + assertThat(service.relabelFeesForOrder("10001", 91L)).containsExactly(fee); + assertThat(service.relabelFeesForOrder("10002", 91L)).isEmpty(); + verify(sources, never()).listForBusiness(eq("10002"), anyString(), anyLong()); + } + + /** 列表与详情统一展示主档名称,仅修改查询对象,不改客户归属、费用快照或资金。 */ + @Test void displaysCurrentCustomerNameWithoutRewritingFeeOrFunding() { + fee.setCustomerName("旧客户-91"); + when(customerNames.resolve(anyCollection())).thenReturn(java.util.Map.of("10001", "客户-66")); + when(repository.page(anyMap())).thenReturn(List.of(fee)); + when(repository.count(anyMap())).thenReturn(1L); + assertThat(service.page(1, 20, null, null, null, null, null, null, null, null).records().get(0).getCustomerName()) + .isEqualTo("客户-66"); + fee.setCustomerName("旧客户-91"); + assertThat(service.detail(701L).getCustomerName()).isEqualTo("客户-66"); + assertThat(fee.getCustomerId()).isEqualTo("10001"); + verify(repository, never()).updateFee(any()); + verify(repository, never()).updateFunding(any()); + verifyNoInteractions(wallet, outbox); + } + + /** 客户筛选及业务候选统一主档名称,不继续展示历史名称。 */ + @Test void fillsCustomerAndBusinessOptions() { + when(customerNames.resolve(anyCollection())).thenReturn(java.util.Map.of("10001", "柏悦客户")); + when(businesses.customers()).thenReturn(List.of(new ContainerCustomerOption("10001", "旧客户-91"))); + when(businesses.options("WAREHOUSE_TRANSFER", "10001", null)).thenReturn(List.of( + new BusinessOption(91L, "WAREHOUSE_TRANSFER", "TR-001", "10001", "旧客户-91", null, null, null))); + assertThat(service.customers()).containsExactly(new ContainerCustomerOption("10001", "柏悦客户")); + var option = service.businessOptions("WAREHOUSE_TRANSFER", "10001", null).get(0); + assertThat(option.customerName()).isEqualTo("柏悦客户"); + assertThat(option.businessId()).isEqualTo(91L); + } + + @BeforeEach void setup() { + fee = new OverseasFeeSource(); fee.setId(100L); fee.setSourceFeeId(701L); fee.setSourceVersion(1); + fee.setCustomerId("10001"); fee.setBusinessType("WAREHOUSE_TRANSFER"); fee.setBusinessId(91L); + fee.setBusinessNo("TR-001"); fee.setFeeItemName("出库费"); fee.setDirection("RECEIVABLE"); + fee.setReviewStatus("PENDING"); fee.setSourceStatus("ACTIVE"); fee.setPublicationStatus("UNPUBLISHED"); + fee.setAmount(new BigDecimal("88.00")); fee.setCreatedBy("operator"); fee.setUpdatedBy("operator"); + fee.setCurrency("CNY"); fee.setOriginalCurrency("CNY"); fee.setOriginalAmount(new BigDecimal("88.00")); + fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth("2026-10"); + fee.setBusinessDate(java.time.LocalDate.of(2026, 10, 22)); + funding = new OverseasFeeFunding(); funding.setSourceFeeId(701L); funding.setCustomerId("10001"); + funding.setCollectionMode("WALLET"); funding.setWalletStatus("FROZEN"); funding.setCommandVersion(1L); + funding.setWalletBusinessNo("701-1"); funding.setCommandEventId("freeze-event"); funding.setCommandHistory(new LinkedHashMap<>()); + funding.setWalletHoldId("9001"); + funding.getCommandHistory().put("freeze-event", "FREEZE"); + when(repository.lockFee(701L)).thenReturn(fee); when(repository.lockFunding(701L)).thenReturn(funding); + when(repository.current(701L)).thenReturn(fee); when(repository.funding(anyCollection())).thenReturn(List.of(funding)); + when(wallet.publish(anyString(), eq(10001L), anyString(), anyString(), any(), anyString(), anyLong())) + .thenAnswer(invocation -> invocation.getArgument(0).toString().toLowerCase() + "-event"); + } + + /** 过期详情的审核必须在发出任何扣款指令之前拒绝。 */ + @Test void reviewRejectsStaleVersionBeforeFundingMutation() { + assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "stale-review", 2))) + .hasMessageContaining("版本"); + assertThat(fee.getReviewStatus()).isEqualTo("PENDING"); + assertThat(funding.getWalletStatus()).isEqualTo("FROZEN"); + verifyNoInteractions(wallet, outbox); + } + + @Test void approvalWaitsForActualWalletSettlementBeforePublication() { + var result = service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1")); + assertThat(result.getReviewStatus()).isEqualTo("APPROVED"); + assertThat(result.getPublicationStatus()).isEqualTo("UNPUBLISHED"); + assertThat(result.getWalletStatus()).isEqualTo("SETTLE_PENDING"); + assertThat(result.getWalletSettlement()).isNull(); verifyNoInteractions(outbox); + service.handle(completed("settle-event", "SETTLE", "SUCCEEDED", "SETTLED", "10001", "701-1", "88.00")); + assertThat(fee.getWalletSettlement().ledgerId()).isEqualTo("9002"); + assertThat(fee.getPublicationStatus()).isEqualTo("PUBLISHED"); + assertThat(funding.getWalletStatus()).isEqualTo("SETTLED"); verifyNoInteractions(outbox); + } + + @Test void legacyBillCannotReplayAnApprovalForAnotherFee() { + fee.setBusinessType("LASTLEG_ORDER"); + when(businesses.orderType(91L, "10001")).thenReturn("FIRST_LEG"); + when(repository.lockFunding(701L)).thenReturn(null); + var prior = new OverseasFeeCommand(); prior.setSourceFeeId(702L); + when(repository.command("reviewer", "REVIEW", "legacy-review")).thenReturn(prior); + assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "legacy-review"))) + .hasMessageContaining("其他费用"); + verifyNoInteractions(sources, wallet, outbox); + } + + @Test void financeFailedCallbackWithoutHoldIdCanBeRetried() { + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1")); + service.handle(new WalletCommandCompletedV1("settle-event", "SETTLE", 10001L, + "OVERSEAS_MANUAL_FEE", "701-1", "FAILED", null, null, "FIN-WALLET-409", "稍后重试", + Instant.parse("2026-10-22T00:00:00Z"), null, null)); + assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_FAILED"); + assertThat(funding.getWalletHoldId()).isEqualTo("9001"); + service.retry(701L, "operator", "retry-1"); + assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_PENDING"); + verifyNoInteractions(outbox); + } + + @Test void settledResultReplayDoesNotPublishTwice() { + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1")); + var result = completed("settle-event", "SETTLE", "SUCCEEDED", "SETTLED", "10001", "701-1", "88.00"); + service.handle(result); service.handle(result); + verifyNoInteractions(outbox); + } + + @Test void failedSettlementKeepsUncollectedStateAndAllowsExplicitRetry() { + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "review-1")); + service.handle(completed("settle-event", "SETTLE", "FAILED", "FROZEN", "10001", "701-1", "88.00")); + assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_FAILED"); assertThat(fee.getWalletSettlement()).isNull(); + assertThat(service.detail(701L).getAllowedActions()).contains("RETRY_SETTLE").doesNotContain("APPROVE", "VOID", "ADJUST"); + service.retry(701L, "operator", "retry-1"); + verify(wallet).publish(eq("SETTLE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"), isNull(), anyString(), eq(3L)); + verifyNoInteractions(outbox); + } + + @Test void rejectionDuringPendingFreezeReleasesAfterConfirmedFreeze() { + funding.setWalletStatus("FREEZE_PENDING"); + service.review(701L, "reviewer", new ReviewRequest("REJECT", "金额有误", "reject-1")); + verifyNoInteractions(wallet); + service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "88.00")); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING"); + verify(wallet).publish(eq("RELEASE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"), isNull(), anyString(), eq(2L)); + verifyNoInteractions(outbox); + } + + @Test void cancelledPendingFreezeIsReleasedWithoutPublishingFee() { + funding.setWalletStatus("FREEZE_PENDING"); + service.cancel(701L, "operator", "cancel-1"); + assertThat(fee.getSourceStatus()).isEqualTo("VOIDED"); + service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "88.00")); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING"); verifyNoInteractions(outbox); + } + + @Test void insufficientFundsCannotBeApprovedButCanBeRejectedOrCancelled() { + funding.setWalletStatus("FREEZE_FAILED"); + assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "approve"))) + .hasMessageContaining("资金尚未锁定"); + assertThat(service.detail(701L).getAllowedActions()).contains("REJECT", "RETRY_FREEZE").doesNotContain("APPROVE", "CANCEL"); + service.review(701L, "reviewer", new ReviewRequest("REJECT", "金额录错", "reject")); + assertThat(fee.getReviewStatus()).isEqualTo("REJECTED"); verifyNoInteractions(wallet, outbox); + } + + @Test void foreignCustomerMismatchedCommandAndAmountAreRejected() { + funding.setWalletStatus("FREEZE_PENDING"); + assertThatThrownBy(() -> service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10002", "701-1", "88.00"))) + .hasMessageContaining("客户不匹配"); + assertThatThrownBy(() -> service.handle(completed("wrong-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "88.00"))) + .hasMessageContaining("指令不匹配"); + assertThatThrownBy(() -> service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-1", "99.00"))) + .hasMessageContaining("金额或冻结引用不匹配"); + verifyNoInteractions(wallet, outbox); + } + + /** 驳回后无论是否释放完毕,都不能重提、取消或覆盖审核人和原因。 */ + @Test void rejectedFeeIsTerminalAndPreservesOriginalReview() { + fee.setReviewStatus("REJECTED"); funding.setWalletStatus("RELEASED"); funding.setCommandVersion(3L); + fee.setReviewReason("金额录错"); fee.setReviewerId("original-reviewer"); + fee.setReviewedAt(java.time.LocalDateTime.of(2026, 10, 23, 10, 0)); + assertThatThrownBy(() -> service.submit(701L, "operator", "submit-2")).hasMessageContaining("不能提交"); + assertThatThrownBy(() -> service.cancel(701L, "operator", "cancel-2")).hasMessageContaining("已驳回"); + assertThat(service.detail(701L).getAllowedActions()).isEmpty(); + assertThat(fee.getReviewStatus()).isEqualTo("REJECTED"); + assertThat(fee.getReviewReason()).isEqualTo("金额录错"); assertThat(fee.getReviewerId()).isEqualTo("original-reviewer"); + assertThat(fee.getReviewedAt()).isEqualTo(java.time.LocalDateTime.of(2026, 10, 23, 10, 0)); + verifyNoInteractions(wallet, outbox); verify(repository, never()).updateFee(any()); + } + + /** 已冻结费用驳回后最终释放余额和授信占用,重复回执不再发任何扣款。 */ + @Test void rejectedFrozenFeeCompletesReleaseAndCanNeverBeApprovedAgain() { + service.review(701L, "reviewer", "审核员", new ReviewRequest("REJECT", "金额录错", "reject-frozen")); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_PENDING"); + var result = completed("release-event", "RELEASE", "SUCCEEDED", "RELEASED", "10001", "701-1", "88.00"); + service.handle(result); service.handle(result); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASED"); + assertThat(fee.getReviewReason()).isEqualTo("金额录错"); assertThat(fee.getReviewerName()).isEqualTo("审核员"); + assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "approve-after-reject"))).hasMessageContaining("待审核"); + verify(wallet, times(1)).publish(eq("RELEASE"), anyLong(), anyString(), eq("701-1"), isNull(), anyString(), eq(2L)); + verify(wallet, never()).publish(eq("SETTLE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong()); + } + + /** 资金释放失败仍可走补偿,补偿不会重开原费用或改变原审核记录。 */ + @Test void rejectedReleaseFailureCanRecoverWithoutReopeningFee() { + service.review(701L, "reviewer", new ReviewRequest("REJECT", "金额录错", "reject-release")); + service.handle(completed("release-event", "RELEASE", "FAILED", "FROZEN", "10001", "701-1", "88.00")); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASE_FAILED"); + assertThat(service.detail(701L).getAllowedActions()).containsExactly("RETRY_RELEASE"); + when(wallet.publish(eq("RELEASE"), anyLong(), anyString(), anyString(), any(), anyString(), eq(3L))).thenReturn("release-retry"); + service.retry(701L, "operator", "release-retry-key"); + service.handle(completed("release-retry", "RELEASE", "SUCCEEDED", "RELEASED", "10001", "701-1", "88.00")); + assertThat(fee.getReviewStatus()).isEqualTo("REJECTED"); assertThat(fee.getReviewReason()).isEqualTo("金额录错"); + assertThat(funding.getWalletStatus()).isEqualTo("RELEASED"); + assertThat(service.detail(701L).getAllowedActions()).isEmpty(); + } + + /** 新建同项目时先解释用途,校验发生在任何资金冻结之前。 */ + @Test void duplicateProjectRequiresRemarkButAllowsAnotherRealService() throws Exception { + var request = relabelDeliveryRequest(); + when(sources.create(eq("operator"), anyString(), any())).thenReturn(fee); + when(repository.lockFunding(701L)).thenReturn(null); + when(repository.hasSameProject(fee)).thenReturn(true); + assertThatThrownBy(() -> service.create("operator", "duplicate-no-remark", request)).hasMessageContaining("费用说明"); + verifyNoInteractions(wallet, outbox); verify(repository, never()).insertFunding(any()); + fee.setFeeRemark("第二次预约补收"); + var created = service.create("operator", "duplicate-with-remark", request); + assertThat(created.getFeeRemark()).isEqualTo("第二次预约补收"); + verify(wallet, times(1)).publish(eq("FREEZE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong()); + } + + @Test void firstLegAdditionalFeeApprovalPublishesBillWithoutWalletCommand() { + funding.setCollectionMode("BILL"); funding.setWalletStatus("NOT_REQUIRED"); + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "head-leg-approve")); + assertThat(fee.getPublicationStatus()).isEqualTo("PUBLISHED"); assertThat(fee.getWalletSettlement()).isNull(); + verify(outbox).publish(fee, "PUBLISHED"); verifyNoInteractions(wallet); + } + /** 尾程各业务供应商应付统一进入账单结算,不冻结或扣客户余额。 */ + @Test void supplierPayablesAlwaysInitializeBillAndPublishAfterReview() { + fee.setDirection("PAYABLE"); fee.setBusinessType("LASTLEG_ORDER"); fee.setReviewStatus("DRAFT"); + when(repository.lockFunding(701L)).thenReturn(null); + var submitted = service.submit(701L, "operator", "payable-submit"); + assertThat(submitted.getCollectionMode()).isEqualTo("BILL"); assertThat(submitted.getWalletStatus()).isEqualTo("NOT_REQUIRED"); + var captured = ArgumentCaptor.forClass(OverseasFeeFunding.class); verify(repository).insertFunding(captured.capture()); + when(repository.lockFunding(701L)).thenReturn(captured.getValue()); + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "payable-approve")); + verify(outbox).publish(fee, "PUBLISHED"); verifyNoInteractions(wallet); + } + + @Test void manualCreateReservesAfterCreatingTheSingleFeeSource() { + var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"); + when(sources.create("operator", "create-1", request)).thenReturn(fee); when(repository.lockFunding(701L)).thenReturn(null); + var result = service.create("operator", "create-1", request); + assertThat(result.getReviewStatus()).isEqualTo("PENDING"); assertThat(result.getWalletStatus()).isEqualTo("FREEZE_PENDING"); + verify(catalog).requireActive(request.feeItemCode(), request.feeItemName(), "RECEIVABLE"); + ArgumentCaptor captured = ArgumentCaptor.forClass(OverseasFeeFunding.class); + verify(repository).insertFunding(captured.capture()); assertThat(captured.getValue().getSourceFeeId()).isEqualTo(701L); + verifyNoInteractions(outbox); + } + + /** 海外业务尾程单不能因统一的 LASTLEG_ORDER 类型误走头程账单。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"WAREHOUSE_TRANSFER", "RETURN_RELABEL", "ONE_CLICK_DROPSHIP"}) + void localTailOrderFeesReserveWallet(String orderType) { + fee.setBusinessType("LASTLEG_ORDER"); + when(businesses.orderType(91L, "10001")).thenReturn(orderType); + when(repository.lockFunding(701L)).thenReturn(null); + var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"); + when(sources.create("operator", "local-fee", request)).thenReturn(fee); + var result = service.create("operator", "local-fee", request); + assertThat(result.getCollectionMode()).isEqualTo("WALLET"); + assertThat(result.getWalletStatus()).isEqualTo("FREEZE_PENDING"); + verify(wallet).publish(eq("FREEZE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), anyString(), + eq(new BigDecimal("88.00")), anyString(), eq(1L)); + verifyNoInteractions(outbox); + } + + /** 本土卡车与散板应收沿钱包冻结、审核实扣,不转入账单。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"US_TRUCK", "LOCAL_LOOSEBOARD"}) + void localTransportReceivablesReserveWallet(String businessType) { + fee.setBusinessType(businessType); fee.setReviewStatus("DRAFT"); + when(repository.lockFunding(701L)).thenReturn(null); + var result = service.submit(701L, "operator", "transport-submit"); + assertThat(result.getCollectionMode()).isEqualTo("WALLET"); + assertThat(result.getWalletStatus()).isEqualTo("FREEZE_PENDING"); + var captured = ArgumentCaptor.forClass(OverseasFeeFunding.class); + verify(repository).insertFunding(captured.capture()); + when(repository.lockFunding(701L)).thenReturn(captured.getValue()); + captured.getValue().setWalletStatus("FROZEN"); + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "transport-approve")); + verify(wallet).publish(eq("SETTLE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), anyString(), + isNull(), anyString(), eq(2L)); + verifyNoInteractions(outbox); + } + + /** 自动仓租项目不能通过人工入口重复建应收或锁款。 */ + @Test void automaticRentItemCannotBeCreatedManually() throws Exception { + var mapper = new ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.node.ObjectNode data = mapper.valueToTree( + OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567")); + data.put("feeItemCode", "FEE_TYPE_077"); data.put("feeItemName", "仓储费"); + var request = mapper.treeToValue(data, com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); + assertThatThrownBy(() -> service.create("operator", "manual-rent", request)) + .hasMessageContaining("仓租自动计算"); + verifyNoInteractions(sources, repository, catalog, wallet, outbox); + } + + @Test void createReplayDoesNotSendSecondFreezeCommand() { + var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"); + when(sources.create("operator", "create-1", request)).thenReturn(fee); + service.create("operator", "create-1", request); verifyNoInteractions(wallet, outbox); + } + + @Test void automaticSettledStorageFeeCannotBeCancelledAdjustedOrVoided() { + fee.setWalletSettlement(new com.baiyue.common.event.WalletSettlementReference("e", "h", "l")); + assertThat(service.detail(701L).getAllowedActions()).isEmpty(); + assertThatThrownBy(() -> service.cancel(701L, "operator", "cancel")).hasMessageContaining("不能取消"); + } + + /** 原币USD保留,钱包必须按已固化人民币快照冻结和核对回执。 */ + @Test void usdFeeFreezesConvertedCnyAndRejectsOriginalCurrencyAmount() { + fee.setOriginalCurrency("USD"); fee.setOriginalAmount(new BigDecimal("10.00")); + fee.setExchangeRate(new BigDecimal("7.200000")); fee.setAmount(new BigDecimal("72.00")); + fee.setReviewStatus("DRAFT"); funding.setWalletStatus("FREEZE_FAILED"); + service.submit(701L, "operator", "usd-submit"); + verify(wallet).publish(eq("FREEZE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-2"), + eq(new BigDecimal("72.00")), anyString(), eq(2L)); + assertThatThrownBy(() -> service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-2", "10.00"))) + .hasMessageContaining("金额或冻结引用不匹配"); + service.handle(completed("freeze-event", "FREEZE", "SUCCEEDED", "FROZEN", "10001", "701-2", "72.00")); + assertThat(funding.getWalletStatus()).isEqualTo("FROZEN"); + } + + /** 历史海外待审费用不得继续沿原审核入口直接发布账单。 */ + @Test void oldOverseasPendingFeeMustBeSubmittedToReserveBeforeReview() { + when(repository.lockFunding(701L)).thenReturn(null); + assertThatThrownBy(() -> service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "legacy-review"))) + .hasMessageContaining("重新提交"); + service.submit(701L, "operator", "legacy-submit"); + verify(repository).insertFunding(any()); verify(wallet).publish(eq("FREEZE"), eq(10001L), anyString(), anyString(), + eq(new BigDecimal("88.00")), anyString(), eq(1L)); verifyNoInteractions(outbox); + } + + /** 一件代发与头程共用订单表,结算方式按当前业务类型分别确定。 */ + @Test void dropshipOrderUsesWalletWhereFirstLegUsesBill() { + fee.setBusinessType("LASTLEG_ORDER"); when(businesses.orderType(91L, "10001")).thenReturn("ONE_CLICK_DROPSHIP"); + when(repository.lockFunding(701L)).thenReturn(null); + service.submit(701L, "operator", "dropship-submit"); verify(wallet).publish(eq("FREEZE"), anyLong(), anyString(), anyString(), any(), anyString(), anyLong()); + } + + /** 回执必须携带完整成功事实,不能用其他冻结记录替换本次锁款。 */ + @Test void invalidResultAndForeignHoldCannotSettleFee() { + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "approve")); + var otherHold = new WalletCommandCompletedV1("settle-event", "SETTLE", 10001L, "OVERSEAS_MANUAL_FEE", "701-1", + "SUCCEEDED", "SETTLED", new BigDecimal("88.00"), null, null, Instant.now(), "other", "ledger"); + assertThatThrownBy(() -> service.handle(otherHold)).hasMessageContaining("原锁款不匹配"); + var invalid = new WalletCommandCompletedV1("settle-event", "SETTLE", 10001L, "OVERSEAS_MANUAL_FEE", "701-1", + "UNKNOWN", null, null, null, null, null, null, null); + assertThatThrownBy(() -> service.handle(invalid)).hasMessageContaining("字段不完整"); + verifyNoInteractions(outbox); + } + + /** 头程附加费建单不冻结,审核后才进入头程账单。 */ + @Test void firstLegCreationUsesBillAndRequiresApprovalBeforePublication() { + fee.setBusinessType("LASTLEG_ORDER"); when(businesses.orderType(91L, "10001")).thenReturn("FIRST_LEG"); + when(repository.lockFunding(701L)).thenReturn(null); + var request = OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"); + when(sources.create("operator", "firstleg-create", request)).thenReturn(fee); + var created = service.create("operator", "firstleg-create", request); + assertThat(created.getCollectionMode()).isEqualTo("BILL"); assertThat(created.getReviewStatus()).isEqualTo("PENDING"); + verifyNoInteractions(wallet, outbox); + } + + /** 旧失败回执在重试后重放不覆盖新扣款,成功仍只入池一次。 */ + @Test void staleFailureReplayCannotOverwriteRetrySettlement() { + when(wallet.publish(eq("SETTLE"), anyLong(), anyString(), anyString(), any(), anyString(), eq(3L))).thenReturn("settle-retry"); + service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "retry-review")); + var oldFailure = completed("settle-event", "SETTLE", "FAILED", "FROZEN", "10001", "701-1", "88.00"); + service.handle(oldFailure); service.retry(701L, "operator", "retry-key"); service.handle(oldFailure); + assertThat(funding.getWalletStatus()).isEqualTo("SETTLE_PENDING"); + service.handle(completed("settle-retry", "SETTLE", "SUCCEEDED", "SETTLED", "10001", "701-1", "88.00")); + assertThat(funding.getWalletStatus()).isEqualTo("SETTLED"); verifyNoInteractions(outbox); + } + + /** 采购费用沿原采购锁款结算,禁止由旧通用提交入口再扣一次。 */ + @Test void procurementCannotEnterGenericFundingEvenWhenLegacy() { + fee.setBusinessType("PROCUREMENT_FEE"); when(repository.lockFunding(701L)).thenReturn(null); + assertThatThrownBy(() -> service.submit(701L, "operator", "procurement-submit")).hasMessageContaining("采购费用"); + assertThat(service.detail(701L).getAllowedActions()).isEmpty(); verifyNoInteractions(wallet, outbox); + } + + @Test void oldRelabelHoldBlocksEquivalentCatalogItemButNotAnotherService() { + fee.setBusinessType("RELABEL_ORDER"); fee.setFeeItemCode("FEE_TYPE_071"); fee.setFeeItemName("贴标费"); + fee.setReviewStatus("DRAFT"); when(repository.lockFunding(701L)).thenReturn(null); + when(repository.legacyRelabel("10001","RELABEL_ORDER",91L)).thenReturn(1); + assertThatThrownBy(() -> service.submit(701L,"operator","legacy-label")).hasMessageContaining("旧版收费"); + verifyNoInteractions(wallet,outbox); + fee.setFeeItemCode("FEE_TYPE_075"); fee.setFeeItemName("派送费"); + service.submit(701L,"operator","different-service"); + verify(wallet).publish(eq("FREEZE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + + @Test void oldAutomaticReceivableBlocksSameServiceBeforeNewHold() { + fee.setReviewStatus("DRAFT"); when(repository.lockFunding(701L)).thenReturn(null); + when(repository.legacyProject(anyString(),anyString(),anyLong(),any(),any(),anyLong())).thenReturn(1); + assertThatThrownBy(() -> service.submit(701L,"operator","legacy-project")).hasMessageContaining("旧版收费"); + verifyNoInteractions(wallet,outbox); + } + + private WalletCommandCompletedV1 completed(String event, String operation, String result, String holdStatus, + String customer, String businessNo, String amount) { + return new WalletCommandCompletedV1(event, operation, Long.parseLong(customer), "OVERSEAS_MANUAL_FEE", businessNo, + result, holdStatus, new BigDecimal(amount), "FAILED".equals(result) ? "INSUFFICIENT" : null, + "FAILED".equals(result) ? "余额不足" : null, Instant.parse("2026-10-22T00:00:00Z"), "9001", "9002"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java index 14fd1c29b..808e42279 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java @@ -16,6 +16,24 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class ProcurementSettledFeeSourceTest { + /** 本土运输不得通过应付来源绕开客户余额和授信扣费。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"US_TRUCK,OVERSEAS_TRUCK_FEE", "LOCAL_LOOSEBOARD,OVERSEAS_LOOSEBOARD_FEE"}) + void rejectsLocalTransportPayablesBeforeWriting(String businessType, String sourceType) { + var mapper = mock(OverseasFeeSourceMapper.class); + var outbox = mock(OverseasFeeOutboxService.class); + var converter = mock(OverseasFeeCurrencyConverter.class); + var guard = mock(OverseasFeeBusinessGuard.class); + var service = new OverseasFeeSourceService(mapper, mock(OverseasFeeCommandMapper.class), guard, + outbox, converter, new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class)); + var request = new SaveRequest("9", "客户", sourceType, businessType, 11L, "LOCAL-1", + null, null, null, null, null, null, null, "FREIGHT", "运费", null, "PAYABLE", + 71L, "供应商", "TRUCKING", "ON_ACCOUNT", null, null, null, List.of(), + LocalDate.of(2026, 10, 23), "CNY", new BigDecimal("88.00"), null, null, null, null, null); + assertThatThrownBy(() -> service.create("7", "local-payable", request)).hasMessageContaining("只能登记应收"); + verifyNoInteractions(mapper, outbox, converter, guard); + } + @Test void publishesActualFeeUsingConfirmedFxAndConsumptionReference() { var mapper = mock(OverseasFeeSourceMapper.class); @@ -25,7 +43,7 @@ class ProcurementSettledFeeSourceTest { mock(OverseasFeeBusinessGuard.class), outbox, converter, new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class)); SaveRequest fee = new SaveRequest("9", "customer", "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", 11L, - "PF-1", 12L, null, null, null, null, null, null, "SAMPLE_PAYMENT", "样品货款", "RECEIVABLE", + "PF-1", 12L, null, null, null, null, null, null, "SAMPLE_PAYMENT", "样品货款", null, "RECEIVABLE", null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 10, 9), "USD", new BigDecimal("10.00"), BigDecimal.ONE, "ITEM", new BigDecimal("10.00"), null, null); assertThatThrownBy(() -> service.create("7", "manual-source", fee)).hasMessageContaining("采购费用必须通过"); @@ -39,7 +57,7 @@ class ProcurementSettledFeeSourceTest { assertThat(result.getWalletSettlement()).isEqualTo(reference); assertThat(result.getReviewStatus()).isEqualTo("APPROVED"); assertThat(result.getPublicationStatus()).isEqualTo("PUBLISHED"); - verify(outbox).publish(result, "PUBLISHED"); + verifyNoInteractions(outbox); verify(mapper).insert(result); verifyNoInteractions(converter); assertThatThrownBy(() -> service.publishSettled("7", "source-2", fee, conversion, null)) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 79430bd4c..cc62bf9de 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -59,6 +59,7 @@ class InboundStockReceiptServiceTest { @BeforeEach void setUp() { service = new InboundStockReceiptService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "receipts", receipts); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java index 1d6ae96db..dd62b04eb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java @@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class DestuffingPalletCostServiceTest { - @Test void publishesDestuffingReceivableAsOverseasContainerBusiness() { + @Test void retainsCostClassificationForManualCustomerCharging() { assertThat(DestuffingPalletCostService.DESTUFF_BUSINESS_TYPE).isEqualTo("OVERSEAS_CONTAINER"); } @Test void newestCompletedBookingsAreNotStarvedByOldUnbillableRows() { @@ -20,6 +20,27 @@ class DestuffingPalletCostServiceTest { .contains("pp.actual_package_count>0") .contains("pp.actual_volume_cbm>0"); } + /** 真实完成的成本仅写分摊依据,不产生客户应收关联。 */ + @Test void completedInboundRecordsCostAllocationWithoutFeeSource() { + var jdbc=org.mockito.Mockito.mock(org.springframework.jdbc.core.JdbcTemplate.class); + var pallets=org.mockito.Mockito.mock(com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.class); + var service=new DestuffingPalletCostService(jdbc,pallets); + org.mockito.Mockito.when(jdbc.queryForObject(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.eq(Integer.class),org.mockito.ArgumentMatchers.eq("77"))).thenReturn(0); + org.mockito.Mockito.when(jdbc.queryForList(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.eq("77"))).thenReturn(List.of(java.util.Map.of("status","COMPLETED","container_no","CONT-77","overseas_warehouse_id",8L))); + org.mockito.Mockito.when(jdbc.queryForList(DestuffingPalletCostService.RECEIVED_PALLET_IDS_SQL,Long.class,"77")).thenReturn(List.of(1L)); + org.mockito.Mockito.when(pallets.resolve(1L)).thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(1,"P-1",8,null,"AVAILABLE",null,BigDecimal.TEN,1)); + org.mockito.Mockito.when(jdbc.query(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.>any(),org.mockito.ArgumentMatchers.eq("77"))) + .thenAnswer(invocation -> { + var rs=org.mockito.Mockito.mock(java.sql.ResultSet.class); + org.mockito.Mockito.when(rs.getString("customer_id")).thenReturn("10001"); + org.mockito.Mockito.when(rs.getBigDecimal("actual_volume")).thenReturn(BigDecimal.ONE); + return List.of(((org.springframework.jdbc.core.RowMapper)invocation.getArgument(1)).mapRow(rs,0)); + }); + service.generateIfReady("77","operator"); + org.mockito.Mockito.verify(jdbc).update(org.mockito.ArgumentMatchers.contains("VALUES(?,?,?,?,?,NULL,NOW(3))"), + org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.eq("10001"),org.mockito.ArgumentMatchers.eq(BigDecimal.ONE),org.mockito.ArgumentMatchers.eq(new BigDecimal("10.00"))); + } + @Test void preservesTotalAndLetsFinalCustomerAbsorbRoundingRemainder() { List result = DestuffingPalletCostService.allocate(new BigDecimal("10.00"), List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index a4b2578d3..8d52bdc3d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -43,6 +43,7 @@ class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementStatusHistoryMapper histories; @Mock ProcurementWarehouseDirectory warehouseDirectory; + @Mock com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository; @Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator; @Mock OverseasProcurementFileMapper procurementFiles; @Mock OverseasProcurementFeeMapper procurementFees; @@ -57,6 +58,7 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); + ReflectionTestUtils.setField(service, "directionRepository", directionRepository); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); @@ -190,4 +192,51 @@ class OverseasProcurementServiceTest { assertEquals("label.pdf",service.downloadFile("100","HC1",42).fileName()); verify(fileGateway).download("100",1,"LABEL",42); } + + @Test void exportReviewPersistsDirectionAndCannotReplayWithChangedDirection() { + var order = order(OverseasProcurementService.PENDING_REVIEW,0,0); + when(orders.lockNo("100","HC1")).thenReturn(order); + when(orders.findNo("100","HC1")).thenReturn(order); + when(histories.listOrder("100",1L)).thenReturn(List.of()); + when(warehouseDirectory.required(10L,ProcurementDirection.EXPORT)).thenReturn( + new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(10L,"SZX","国内采购仓","CN","ACTIVE")); + var request = new OverseasProcurementRequests.Review(0L,"APPROVE","export-review","已核对海外收件资料",10L,ProcurementDirection.EXPORT); + var result = service.review("100","HC1","7",request); + assertEquals(ProcurementDirection.EXPORT,result.summary().procurementDirection()); + assertEquals("国内采购仓",result.summary().purchaseWarehouseName()); + assertEquals(1L,service.review("100","HC1","7",request).summary().version()); + assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7", + new OverseasProcurementRequests.Review(0L,"APPROVE","export-review","已核对海外收件资料",10L,ProcurementDirection.IMPORT))); + verify(warehouseDirectory,never()).required(10L); + } + + @Test void oldRequestHashDoesNotGainNullDirectionField() throws Exception { + var request = new OverseasProcurementRequests.Review(0L,"APPROVE","old-key","checked",10L); + var json = new ObjectMapper().writeValueAsString(request); + assertEquals(false,json.contains("procurementDirection")); + assertEquals(ProcurementDirection.IMPORT,ProcurementDirection.normalize(null)); + } + + + @Test void pendingReviewCannotReverseDirectionOfAnAlreadyRecordedPurchase() { + var order = order(OverseasProcurementService.PENDING_REVIEW,0,0); + when(orders.lockNo("100","HC1")).thenReturn(order); + when(directionRepository.hasStarted(1L)).thenReturn(true); + assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7", + new OverseasProcurementRequests.Review(0L,"APPROVE","export-after-purchase","checked",10L,ProcurementDirection.EXPORT))); + verify(warehouseDirectory,never()).required(10L,ProcurementDirection.EXPORT); + assertEquals(OverseasProcurementService.PENDING_REVIEW,order.getStatus()); + } + + @Test void clientCanCreateDomesticPurchaseForAnOverseasRecipient() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; }); + when(orders.findNo(anyString(),anyString())).thenAnswer(ignored -> saved.get()); + when(histories.listOrder(anyString(),any())).thenReturn(List.of()); + var detail=service.create("100","7",new OverseasProcurementRequests.Create("Acme","Office","Sample","A",null,10L, + BigDecimal.TEN,"CNY","US, California, test destination","Buyer","+1 1234567890",null,"export-create",ProcurementDirection.EXPORT)); + assertEquals(ProcurementDirection.EXPORT,detail.summary().procurementDirection()); + assertEquals("US, California, test destination",detail.receivingAddress()); + assertEquals(OverseasProcurementService.PENDING_REVIEW,detail.summary().status()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java index 5eda4b1ca..4ba1cf4f8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java @@ -81,9 +81,8 @@ class ProcurementFinancePersistenceTest { var saved=fees.selectById(fee.getId());assertThat(saved.getWalletStatus()).isEqualTo("SETTLED"); assertThat(saved.getSourceFeeId()).isEqualTo(901L); verify(sources).publishSettled(eq("7"),anyString(),any(),any(),argThat(ref -> ref.commandEventId().equals(settled.commandEventId()))); - assertThat(service.sourceLinked(saved)).isFalse(); - jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); assertThat(service.sourceLinked(saved)).isTrue(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero(); } @Test void fullInboundNeverSettlesEstimateAndActualUsesOriginalHold() { @@ -205,10 +204,8 @@ class ProcurementFinancePersistenceTest { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2); verify(sources,times(2)).publishSettled(anyString(),anyString(),any(),any(),argThat(ref->ref.commandEventId().equals(result.commandEventId()))); verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); - assertThat(service.sourceLinked(recovered)).isFalse(); - assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("来源关联"); - jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); assertThat(service.sourceLinked(recovered)).isTrue(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero(); service.requireShipmentReady(11); } @@ -260,8 +257,7 @@ class ProcurementFinancePersistenceTest { assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金"); handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100")); handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100")); - assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("来源关联"); - jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero(); service.requireShipmentReady(11); tx.execute(s->service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","pending-refund")); assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java index 0bf8057c8..1c674e655 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -65,4 +65,13 @@ class ProcurementQueryServiceTest { fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)"); assertThat(query.customer("9","PO-1").boxes()).isEmpty(); } + + @Test void exportProjectionExposesDirectionAndLabelsWithoutRewritingHistoricalLegs() { + fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=? WHERE id=11",ProcurementDirection.EXPORT); + var result=query.platform("9","PO-1",Set.of("platform:overseas-business:purchase:receive-overseas")); + assertThat(result.customerView().order().procurementDirection()).isEqualTo(ProcurementDirection.EXPORT); + assertThat(result.customerView().progress()).extracting(ProcurementQueryService.Progress::title).contains("国内收货完成","海外收货完成","海外费用核定"); + assertThat(result.actions()).filteredOn(a -> a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a -> assertThat(a.label()).isEqualTo("登记国内实收")); + } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java index 50f1bbacf..5952dc31b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java @@ -30,6 +30,7 @@ import static org.mockito.ArgumentMatchers.*; class ProcurementTransportPersistenceTest { JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders; + ProcurementWarehouseDirectory warehouses; ProcurementTransportService service;ProcurementFinanceService finance;OverseasProcurementFee estimate; final Address warehouse=new Address("Overseas","1234567890","US operating address"); final Address domestic=new Address("Domestic","13800000000","Shanghai receiving address"); @@ -60,7 +61,7 @@ class ProcurementTransportPersistenceTest { jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'C1',20,30,40,6.125,6),(202,11,301,'C2',20,30,40,4.125,4)"); jdbc.update("INSERT INTO ll_procurement_carton_source VALUES(201,101,6),(202,101,4)"); jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','LABEL',1001,'labels.pdf','application/pdf',10,'7'),(502,11,'9','EXCEPTION',1002,'proof.pdf','application/pdf',10,'7')"); - var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse); + warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse); finance=mock(ProcurementFinanceService.class);var payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED"); when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true); estimate=new OverseasProcurementFee();estimate.setId(801L);estimate.setAmount(BigDecimal.ZERO);estimate.setWalletStatus("ZERO"); @@ -172,4 +173,32 @@ class ProcurementTransportPersistenceTest { private void apply(){tx.execute(s->service.applyLabels("9","PO-1","7",new ApplyLabels(version(),"apply-"+version(),labelVersion(Leg.OVERSEAS),List.of(201L,202L),start.plusMinutes(30),"attached")));} private Ship shipRequest(Leg leg,String key){return new Ship(version(),key,leg,labelVersion(leg),List.of(201L,202L),start.plusMinutes(leg==Leg.OVERSEAS?60:100),"actual handoff");} private ProcurementTransportService.Snapshot ship(Leg leg,String key){return tx.execute(s->service.ship("9","PO-1","7",shipRequest(leg,key)));} + + @Test void exportUsesDomesticSenderAndOverseasDestinationThenEndsAfterLocalHandoff() { + var sender = new Address("Domestic warehouse","13800000000","Shenzhen domestic origin"); + var receiver = new Address("Overseas warehouse","+1 1234567890","Los Angeles overseas receiving"); + var finalReceiver = new Address("Overseas customer","+1 9876543210","San Diego customer destination"); + jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=?,contact_name=?,contact_phone=?,receiving_address=? WHERE id=11", + ProcurementDirection.EXPORT,finalReceiver.name(),finalReceiver.phone(),finalReceiver.address()); + when(warehouses.sender(91,ProcurementDirection.EXPORT)).thenReturn(sender); + tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-labels",Leg.OVERSEAS,1,receiver,null,List.of(label(201),label(202)),"export manifest"))); + review(BigDecimal.ZERO); apply(); + assertThat(ship(Leg.OVERSEAS,"export-international").status()).isEqualTo("INTERNATIONAL_TRANSIT"); + String snapshot = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='OVERSEAS'",String.class); + assertThat(snapshot).contains(sender.address(),receiver.address()).doesNotContain(warehouse.address()); + jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(102,11,'9','PR-2','DOMESTIC',201,6,0,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65)); + jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=? WHERE order_id=11",start.plusMinutes(70)); + tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-local-labels",Leg.DOMESTIC,1,finalReceiver,null,List.of(label(201),label(202)),"overseas delivery"))); + assertThatThrownBy(() -> ship(Leg.DOMESTIC,"before-charges")).hasMessageContaining("费用尚未确认"); + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(80)); + var request = shipRequest(Leg.DOMESTIC,"export-local"); + assertThat(tx.execute(s -> service.ship("9","PO-1","7",request)).status()).isEqualTo("SHIPPED_TO_CUSTOMER"); + tx.execute(s -> service.ship("9","PO-1","7",request)); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(2); + String local = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='DOMESTIC'",String.class); + assertThat(local).contains(receiver.address(),finalReceiver.address()); + verify(warehouses,never()).sender(91); + verify(finance).requireShipmentReady(11); + } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectoryTest.java index 830515e7c..2effd3069 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectoryTest.java @@ -32,4 +32,25 @@ class ProcurementWarehouseDirectoryTest { receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(92L,9L,"LAX","Other","OVERSEAS","US","ACTIVE","Receiver","1234567890"),List.of(address)))); assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("暂不可用"); } + + @Test void domesticDirectionUsesDomesticCatalogAndChecksMasterScopeTypeAndStatus() { + ReflectionTestUtils.setField(directory,"token","test-internal"); + when(client.domesticWarehouses("test-internal")).thenReturn(ApiResponse.success(List.of(new DomesticWarehouse(91L,"SZX","深圳仓")))); + assertThat(directory.options(ProcurementDirection.EXPORT)).singleElement().satisfies(w -> assertThat(w.warehouseId()).isEqualTo(91L)); + var domesticAddress = new Address("OPERATING","CN","广东","深圳",null,"518000","测试国内作业地址",null); + when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail( + new Warehouse(91L,0L,"SZX","深圳仓","DOMESTIC","CN","ACTIVE","仓管","13800000000"),List.of(domesticAddress)))); + assertThat(directory.sender(91,ProcurementDirection.EXPORT).address()).contains("深圳","测试国内作业地址"); + verify(client,never()).receivingWarehouse(anyLong(),anyString()); + for (var invalid : List.of( + new Warehouse(91L,0L,"LAX","海外仓","OVERSEAS","US","ACTIVE","仓管","13800000000"), + new Warehouse(91L,9L,"SZX","客户仓","DOMESTIC","CN","ACTIVE","仓管","13800000000"), + new Warehouse(91L,0L,"SZX","停用仓","DOMESTIC","CN","INACTIVE","仓管","13800000000"), + new Warehouse(92L,0L,"SZX","其他仓","DOMESTIC","CN","ACTIVE","仓管","13800000000"))) { + when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(invalid,List.of(domesticAddress)))); + assertThatThrownBy(() -> directory.required(91,ProcurementDirection.EXPORT)).hasMessageContaining("国内公共仓"); + } + assertThatThrownBy(() -> directory.options("INVALID")).hasMessageContaining("方向无效"); + } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index abcf3d3c0..811824a8f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -61,6 +61,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; @@ -315,6 +316,9 @@ class RelabelApplicationServiceTest { assertEquals("PENDING_REVIEW", relabelHistory.getValue().getToStatus()); assertEquals("PENDING_REVIEW", tailHistory.getValue().getToStatus()); assertEquals("FBR-1", result.application().relabelNo()); + assertEquals("NOT_REQUIRED", application.getValue().getWalletStatus()); + assertEquals(request.chargeAmount(), application.getValue().getChargeAmount()); + verifyNoInteractions(walletCommands); } @Test @@ -831,6 +835,28 @@ class RelabelApplicationServiceTest { assertNull(app.getCustomerConfirmedAt()); } + @Test + void newQuotedApplicationNeverAutoPublishesOnCompletion() { + var app = confirmationApplication("TRUCK", false); app.setWalletStatus("NOT_REQUIRED"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(app); + when(orders.selectById(21L)).thenReturn(order(21L,"10001","LMO-1","WAIT_RELABEL")); + service.settlePdaCompletion("10001","FBR-1","picker"); + verifyNoInteractions(walletCommands,feeSources); + } + + @Test + void legacySettlementPublishesOnlyAfterVerifiedPaidReferenceAndIgnoresReplay() { + var app = confirmationApplication("TRUCK", false); app.setWalletStatus("SETTLE_PENDING"); app.setWalletCommandEventId("settle-legacy"); + when(applications.lockNo("10001","FBR-1")).thenReturn(app); + when(orders.selectById(21L)).thenReturn(order(21L,"10001","LMO-1","WAIT_RELABEL")); + var result = new com.baiyue.common.event.WalletCommandCompletedV1("settle-legacy","SETTLE",10001L,"RELABEL_ORDER","FBR-1", + "SUCCEEDED","SETTLED",app.getChargeAmount(),null,null,java.time.Instant.now(),"hold-1","ledger-1"); + service.handle(result); service.handle(result); + verify(feeSources,times(1)).completeLegacyRelabelSettlement(eq("WALLET_CALLBACK"),eq("RELABEL:FBR-1"),any(), + eq(new com.baiyue.common.event.WalletSettlementReference("settle-legacy","hold-1","ledger-1"))); + assertEquals("SETTLED",app.getWalletStatus()); + } + @Test void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); @@ -1133,16 +1159,11 @@ class RelabelApplicationServiceTest { } @Test - void receivedTransferPublishesFrozenQuoteFeeAndSettleOnceWithoutReleasingStockAgain() { + void receivedLegacyTransferSettlesOnceAndWaitsForConfirmedPaymentBeforePublishing() { RelabelApplication app = transferCompletionFixture(); service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1"); service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1"); - ArgumentCaptor fee = - ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class); - verify(feeSources).publishApproved(eq("operator-1"), eq("RELABEL:FBR-CLOSURE"), fee.capture()); - assertEquals(new BigDecimal("0.01"), fee.getValue().amount()); - assertEquals("CNY", fee.getValue().currency()); assertEquals("RELABEL_ORDER", fee.getValue().businessType()); - assertEquals("FBR-CLOSURE", fee.getValue().businessNo()); assertEquals(BigDecimal.ONE, fee.getValue().chargeableQuantity()); + verifyNoInteractions(feeSources); verify(walletCommands).publish(eq("SETTLE"), eq(10001L), eq("RELABEL_ORDER"), eq("FBR-CLOSURE"), isNull(), eq("换标完成,结算费用"), eq(2L)); assertEquals("COMPLETED", app.getStatus()); assertEquals("COMPLETED", app.getFulfillmentStatus()); @@ -1212,11 +1233,12 @@ class RelabelApplicationServiceTest { } @Test - void feePublicationFailureNeverQueuesSettlement() { + void settlementCommandFailureNeverPublishesFee() { RelabelApplication app = transferCompletionFixture(); - when(feeSources.publishApproved(any(), any(), any())).thenThrow(new IllegalStateException("fee unavailable")); + when(walletCommands.publish(eq("SETTLE"), anyLong(), any(), any(), any(), any(), anyLong())) + .thenThrow(new IllegalStateException("wallet unavailable")); assertThrows(IllegalStateException.class, () -> service.completeWarehouseTransferFulfillment("10001", app.getRelabelNo(), 91L, "operator-1")); - verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong()); + verifyNoInteractions(feeSources); } private RelabelApplication transferCompletionFixture() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java index 44572c72a..a5b431cb3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java @@ -28,12 +28,14 @@ class ReturnCartonCountServiceTest { @Mock ReturnInventoryBalanceMapper balances; @Mock ReturnInventoryOperationMapper operations; @Mock ReturnCartonCountSessionMapper sessions; + @Mock com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; ReturnCartonCountService service; PdaWarehouseIdentity identity = new PdaWarehouseIdentity("op", "operator", 123L); ReturnReceipt receipt; ReturnInventoryCarton a, b; @BeforeEach void setup() { service = new ReturnCartonCountService(); + ReflectionTestUtils.setField(service, "storageRent", storageRent); ReflectionTestUtils.setField(service,"receipts",receipts); ReflectionTestUtils.setField(service,"orders",orders); ReflectionTestUtils.setField(service,"returnReceiptService",returnReceiptService); @@ -82,6 +84,11 @@ class ReturnCartonCountServiceTest { verify(items).deleteCarton(2L); verify(items).deleteCarton(3L); verify(items,times(2)).insert(any(ReturnInventoryCartonItem.class)); verifyNoInteractions(balances); verify(returnReceiptService).refreshCountedTailCargo(receipt); + var mutationOrder=inOrder(storageRent,items); + mutationOrder.verify(storageRent).ensureReturnCountLot(1L); + mutationOrder.verify(items).deleteCarton(2L); + mutationOrder.verify(items).deleteCarton(3L); + mutationOrder.verify(storageRent).rebuildReturnShares(1L); var countedLine = org.mockito.ArgumentCaptor.forClass(ReturnReceiptItem.class); verify(receiptItems).updateById(countedLine.capture()); assertThat(countedLine.getValue().getBoxCount()).isEqualTo(2); @@ -109,6 +116,17 @@ class ReturnCartonCountServiceTest { verify(items,never()).deleteCarton(anyLong()); verify(cartons,never()).updateById(any(ReturnInventoryCarton.class)); verifyNoInteractions(balances); assertThat(a.getCountStatus()).isEqualTo("PENDING"); } + /** 缺失原计租批次未通过核验时,不清点写库也不释放操作者会话。 */ + @Test void rentEvidenceFailurePreventsCountWritesAndSessionRelease() { + prepare(List.of(expected("SKU1",10))); + when(items.lockCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",5))); + when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",5))); + doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"退货签收缺少计租原批次")).when(storageRent).ensureReturnCountLot(1L); + assertThatThrownBy(()->service.confirm(identity,"RR-1",unequal(3,7))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("缺少计租原批次"); + verify(items,never()).deleteCarton(anyLong());verify(cartons,never()).updateById(any(ReturnInventoryCarton.class)); + verify(sessions,never()).release(anyString(),anyLong());verifyNoInteractions(balances,returnReceiptService);verify(operations,never()).insert(any(ReturnInventoryOperation.class)); + verify(storageRent,never()).rebuildReturnShares(anyLong());assertThat(a.getCountStatus()).isEqualTo("PENDING"); + } @Test void duplicateOrForeignBoxesCannotBeSubmitted() { prepare(List.of(expected("SKU1",10))); var request = new Confirm("dup",List.of(measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))),measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index e0ac71b6b..2a6d7cca6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -88,6 +88,7 @@ class ReturnInventoryServiceTest { @BeforeEach void setUp() { service = new ReturnInventoryService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service, "discardMovements", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.repository.ReturnDiscardMovementRepository.class)); ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class)); ReflectionTestUtils.setField(service, "cartons", cartons); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index d0053c248..79f4c998c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -136,6 +136,7 @@ class ReturnReceiptServiceTest { void setUp() { compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new ReturnReceiptService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "202610200001"); ReflectionTestUtils.setField(service, "orders", orders); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java index fce3ab2d7..54c2dfc9e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java @@ -40,6 +40,7 @@ class ReturnSkuConversionPersistenceTest { ReturnInventoryBalanceMapper balances; DataSourceTransactionManager transactions; ReturnSkuMySqlTestDatabase mysql; + com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; @BeforeEach void setUp(TestInfo info) throws Exception { boolean mysqlMode = Boolean.getBoolean("returnSku.test.mysql"); @@ -69,6 +70,8 @@ class ReturnSkuConversionPersistenceTest { var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); var session = new SqlSessionTemplate(factory.getObject()); target = new ReturnSkuConversionService(); + storageRent=mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class); + ReflectionTestUtils.setField(target,"storageRent",storageRent); ReflectionTestUtils.setField(target, "conversions", session.getMapper(ReturnSkuConversionMapper.class)); ReflectionTestUtils.setField(target, "cartons", session.getMapper(ReturnInventoryCartonMapper.class)); ReflectionTestUtils.setField(target, "contents", session.getMapper(ReturnInventoryCartonItemMapper.class)); @@ -188,6 +191,27 @@ class ReturnSkuConversionPersistenceTest { service.confirm(app(1)); assertThrows(ResponseStatusException.class, () -> service.reverse(app(1))); } + /** 实物转换与精确计租迁移快照同事务保存,退回仅使用原快照。 */ + @Test void applyAndReversePersistAndReuseOriginalRentShareSnapshot() { + fixture("BOX");String snapshot="[{\"lotId\":51,\"quantity\":8}]"; + when(storageRent.convertReturnSkuShares("C","W","UNIT-1","A","B",8)).thenReturn(snapshot); + apply(1,11,8,"INITIAL");apply(1,11,8,"INITIAL"); + assertEquals(snapshot,jdbc.queryForObject("SELECT storage_rent_shares_json FROM ll_return_sku_conversion",String.class)); + service.reverse(app(1));service.reverse(app(1)); + verify(storageRent,times(1)).convertReturnSkuShares("C","W","UNIT-1","A","B",8); + verify(storageRent,times(1)).reverseReturnSkuShares("C","W","UNIT-1","A","B",snapshot); + assertStock("A",20,0); + } + + /** 缺失计租来源时,转换已做的商品、余额和台账必须全部回滚。 */ + @Test void rentLineageFailureRollsBackSkuInventoryAndLedger() { + fixture("BOX");when(storageRent.convertReturnSkuShares(anyString(),anyString(),anyString(),anyString(),anyString(),anyLong())).thenThrow(new IllegalStateException("原计租份额缺失")); + assertThrows(IllegalStateException.class,()->apply(1,11,8,"INITIAL")); + assertStock("A",20,0);assertEquals(20,balance("A","total_quantity")); + assertEquals(0,count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='B'")); + assertEquals(0,count("SELECT COUNT(*) FROM ll_return_sku_conversion"));assertEquals(0,count("SELECT COUNT(*) FROM ll_return_packing_trace")); + } + @Test void onlyActualCompletedFactsProduceInventoryAndBulkCountsPieces() { fixture("PARCEL"); var item = item(11, 1, "A", "B", 20); item.setOperationType("RETURN_BULK_SKU_LABEL"); service.apply(app(1), List.of(item), List.of(fact(11, 8, "RELABELLED"), fact(11, 12, "EXCEPTION")), "INITIAL"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index a07004054..205a90d5b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -68,6 +68,7 @@ class LastlegOrderServiceTest { @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock OverseasFeeSourceService feeSources; + @Mock com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees; @Mock LastlegOrderOperationLogMapper operations; @Mock LastlegOrderManualNodeMapper manualNodes; @Mock InboundPlanCartonMapper cartons; @@ -128,6 +129,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); ReflectionTestUtils.setField(service, "feeSources", feeSources); + ReflectionTestUtils.setField(service, "manualFees", manualFees); ReflectionTestUtils.setField(service, "operationLogMapper", operations); ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes); ReflectionTestUtils.setField(service, "cartonMapper", cartons); @@ -243,6 +245,8 @@ class LastlegOrderServiceTest { var response = service.detail("100", 11L); + verify(manualFees).describe(List.of()); + assertEquals(11L, response.order().id()); assertEquals("100", response.order().customerId()); assertEquals("ONE_CLICK_DROPSHIP", response.order().orderType()); @@ -253,6 +257,20 @@ class LastlegOrderServiceTest { .noneMatch(component -> "eventId".equals(component.getName()))); } + /** 换标履约详情同时读取旧订单费用及真实换标申请费用,避免更正归属后漏显示。 */ + @Test + void relabelTailDetailIncludesApplicationAndLegacyOrderFees() { + LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); + order.setOrderType("RELABEL_APPLICATION"); + when(orders.selectOne(any())).thenReturn(order); + var legacy = new OverseasFeeSource(); legacy.setSourceFeeId(701L); + var application = new OverseasFeeSource(); application.setSourceFeeId(702L); + when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of(legacy)); + when(manualFees.relabelFeesForOrder("100", 11L)).thenReturn(List.of(application)); + when(manualFees.describe(List.of(legacy, application))).thenReturn(List.of(legacy, application)); + assertEquals(List.of(legacy, application), service.detail("100", 11L).fees()); + } + @Test void returnTailDetailReadsReceiptFactsWithOwnedCustomerAndSourceReturnNumber() { ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts); @@ -315,21 +333,21 @@ class LastlegOrderServiceTest { void createFeePersistsFeeAndOperationForOwnedOrder() { when(orders.selectOne(any())).thenReturn(order(11L, "100", "PENDING_ASSIGNMENT", null)); var request = new LastlegOrderRequests.CreateFee( - "CUSTOMS_CLEARANCE", "清关费", "RECEIVABLE", "USD", new BigDecimal("420.00"), + "CUSTOMS_CLEARANCE", "清关费", "额外清关作业", "RECEIVABLE", "USD", new BigDecimal("420.00"), LocalDate.of(2026, 9, 9), null, null, null, null, null, null, null, List.of(), null, null, null, null, null); OverseasFeeSource draft = new OverseasFeeSource(); draft.setSourceFeeId(701L); draft.setCustomerId("100"); draft.setFeeItemName("清关费"); draft.setOriginalCurrency("USD"); draft.setOriginalAmount(new BigDecimal("420.00")); - when(feeSources.create(eq("platform_admin"), eq("fee-key"), any())).thenReturn(draft); - when(feeSources.submit(701L, "platform_admin", "fee-key:submit")).thenReturn(draft); + when(manualFees.create(eq("platform_admin"), eq("fee-key"), any())).thenReturn(draft); var fee = service.createFee("100", 11L, request, "platform_admin", "fee-key"); assertEquals("100", fee.getCustomerId()); assertEquals("清关费", fee.getFeeItemName()); - verify(feeSources).create(eq("platform_admin"), eq("fee-key"), any()); - verify(feeSources).submit(701L, "platform_admin", "fee-key:submit"); + verify(manualFees).create(eq("platform_admin"), eq("fee-key"), any()); + verify(feeSources, never()).create(any(), any(), any()); + verify(feeSources, never()).submit(anyLong(), any(), any()); verify(operations).insert(any(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class)); } @@ -408,8 +426,7 @@ class LastlegOrderServiceTest { @Test void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null, - null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, - LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null); + null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(3, 25); resultPage.setTotal(0); when(orders.selectPage(any(), any())).thenReturn(resultPage); @@ -561,12 +578,12 @@ class LastlegOrderServiceTest { var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); resultPage.setRecords(List.of(order)); resultPage.setTotal(1); - when(orders.selectList(any())).thenReturn(resultPage.getRecords()); + when(orders.selectPage(any(), any())).thenReturn(resultPage); when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of( new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"), new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1"))); var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null); var result = service.page(null, query); @@ -588,9 +605,9 @@ class LastlegOrderServiceTest { var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); resultPage.setRecords(List.of(relabel, looseboard, firstleg)); resultPage.setTotal(3); - when(orders.selectList(any())).thenReturn(resultPage.getRecords()); + when(orders.selectPage(any(), any())).thenReturn(resultPage); var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null); var result = service.page(null, query); @@ -619,14 +636,14 @@ class LastlegOrderServiceTest { row.setOverseasWarehouseName("洛杉矶仓"); resultPage.setRecords(matched ? List.of(row) : List.of()); resultPage.setTotal(matched ? 1 : 0); - when(orders.selectList(any())).thenReturn(resultPage.getRecords()); + when(orders.selectPage(any(), any())).thenReturn(resultPage); var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, input, null, null, null, null, null, null, null, null, null)); + null, null, null, null, input, null, null, null, null, null, LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null)); ArgumentCaptor> filter = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); - verify(orders).selectList(filter.capture()); + verify(orders).selectPage(any(), filter.capture()); var wrapper = (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper) filter.getValue(); String warehouseSql = wrapper.getExpression().getNormal().getSqlSegment(); assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_code))")); @@ -636,5 +653,4 @@ class LastlegOrderServiceTest { assertEquals(matched ? 1 : 0, result.total()); assertEquals(matched ? 1 : 0, result.records().size()); } - } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGatewayTest.java new file mode 100644 index 000000000..5b8fc530d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/gateway/StorageRentPricingGatewayTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.storagerent.gateway; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.storagerent.client.CustomerStoragePricingClient; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.service.StorageRentCalculator; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import java.math.BigDecimal; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 客户报价权威响应、网络故障及完整阶梯的关闭式校验。 */ +class StorageRentPricingGatewayTest { + private final CustomerStoragePricingClient client=mock(CustomerStoragePricingClient.class); + private final StorageRentPricingGateway gateway=new StorageRentPricingGateway(); + /** 验证服务令牌和客户身份由真实来源传递。 */ + @BeforeEach void setup(){ReflectionTestUtils.setField(gateway,"client",client);ReflectionTestUtils.setField(gateway,"token","internal-test");} + /** 权威默认不写库且不缓存,客户后续配置可即时生效。 */ + @Test void acceptsExplicitDefaultsAndCustomerOverrideWithoutCache(){ + var defaults=StorageRentCalculator.defaults(101L);var custom=new Pricing("31",101L,2,"CNY",defaults.tiers(),"CUSTOMER"); + when(client.pricing(101L,"internal-test")).thenReturn(ApiResponse.success(defaults),ApiResponse.success(custom)); + assertThat(gateway.current("101")).isEqualTo(defaults);assertThat(gateway.current("101")).isEqualTo(custom); + verify(client,times(2)).pricing(101L,"internal-test"); + } + /** 网络、业务异常或空响应不能以默认零费用放行。 */ + @Test void failsClosedOnNetworkOrEmptyResponse(){ + when(client.pricing(101L,"internal-test")).thenThrow(new IllegalStateException("offline")); + assertThatThrownBy(()->gateway.current("101")).hasMessageContaining("暂不可用"); + reset(client); + when(client.pricing(101L,"internal-test")).thenReturn(null,ApiResponse.success(null)); + assertThatThrownBy(()->gateway.current("101")).hasMessageContaining("不完整");assertThatThrownBy(()->gateway.current("101")).hasMessageContaining("不完整"); + } + /** 公共 Long 序列化可能为字符串,客户和阶梯ID都必须无损读回。 */ + @Test void readsWarehouseStringIdsAndUsesExactInternalRoute() throws Exception { + var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var value=json.readValue("{\"configId\":\"9007199254740993\",\"customerId\":\"101\",\"version\":2,\"currency\":\"CNY\",\"source\":\"CUSTOMER\",\"tiers\":[{\"id\":\"9007199254740995\",\"startDay\":1,\"endDay\":null,\"unitPrice\":2,\"sortNo\":1,\"feeType\":\"STORAGE\",\"billingUnit\":\"CBM\"},{\"id\":12,\"startDay\":1,\"endDay\":null,\"unitPrice\":5,\"sortNo\":2,\"feeType\":\"RETURN_STORAGE\",\"billingUnit\":\"PALLET\"}]}",Pricing.class); + StorageRentPricingGateway.validate(value,101L);assertThat(value.configId()).isEqualTo("9007199254740993");assertThat(value.tiers().get(0).id()).isEqualTo("9007199254740995");assertThat(value.tiers().get(1).id()).isEqualTo("12"); + var method=CustomerStoragePricingClient.class.getMethod("pricing",long.class,String.class); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()).containsExactly("/internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing"); + assertThat(method.getParameters()[1].getAnnotation(org.springframework.web.bind.annotation.RequestHeader.class).value()).isEqualTo("X-Internal-Service-Token"); + } + /** 错客户、错币、漏组、混单位、间断、重叠、有限尾和负价均不能收费。 */ + @Test void rejectsInvalidGroupsCurrencyAndOwnership(){ + var base=StorageRentCalculator.defaults(101L); + for(var value:List.of(new Pricing("1",102L,1,"USD",base.tiers(),"CUSTOMER"),new Pricing("1",101L,1,"EUR",base.tiers(),"CUSTOMER"),new Pricing("1",101L,1,"USD",base.tiers().stream().filter(t->"STORAGE".equals(t.feeType())).toList(),"CUSTOMER"))) + assertThatThrownBy(()->StorageRentPricingGateway.validate(value,101L)).hasMessageContaining("不完整"); + for(var tier:List.of(new PricingTier(null,1,7,BigDecimal.ZERO,1,"STORAGE","PALLET"),new PricingTier(null,2,7,BigDecimal.ZERO,1,"STORAGE","CBM"),new PricingTier(null,1,8,BigDecimal.ZERO,1,"STORAGE","CBM"),new PricingTier(null,1,7,new BigDecimal("-1"),1,"STORAGE","CBM"))) { + var tiers=new ArrayList<>(base.tiers());tiers.set(0,tier); + assertThatThrownBy(()->StorageRentPricingGateway.validate(new Pricing("1",101L,1,"USD",tiers,"CUSTOMER"),101L)).hasMessageContaining("不完整"); + } + var tiers=new ArrayList<>(base.tiers());tiers.set(4,new PricingTier(null,91,100,BigDecimal.ONE,5,"STORAGE","CBM")); + assertThatThrownBy(()->StorageRentPricingGateway.validate(new Pricing("1",101L,1,"USD",tiers,"CUSTOMER"),101L)).hasMessageContaining("不完整"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculatorTest.java new file mode 100644 index 000000000..759557d87 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentCalculatorTest.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.storagerent.service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** 已确认报价和分批出库口径的金额回归。 */ +class StorageRentCalculatorTest { + private final LocalDateTime inbound = LocalDateTime.of(2026, 10, 1, 23, 30); + + /** 入库时刻起算,跨零点不增加一天,任何不足二十四小时的余数进一天。 */ + @Test + void roundsElapsedTwentyFourHoursInsteadOfCalendarDays() { + assertThat(StorageRentCalculator.days(inbound, inbound)).isZero(); + assertThat(StorageRentCalculator.days(inbound, inbound.plusMinutes(31))).isEqualTo(1); + assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(1))).isEqualTo(1); + assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(1).plusNanos(1))).isEqualTo(2); + assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(7))).isEqualTo(7); + assertThat(StorageRentCalculator.days(inbound, inbound.plusDays(7).plusNanos(1))).isEqualTo(8); + } + + /** 中转免费期和每个收费阶梯连续累加,不能按最后档位乘全部天数。 */ + @Test + void accumulatesTransitTiersAtEveryBoundary() { + long[] days = {0, 7, 8, 30, 31, 60, 61, 90, 91}; + String[] amounts = {"0", "0", "0.8", "18.4", "19.4", "48.4", "49.9", "93.4", "95.4"}; + for (int i = 0; i < days.length; i++) + assertThat(StorageRentCalculator.unitAmount("TRANSIT", days[i])).as("第%d天", days[i]).isEqualByComparingTo(amounts[i]); + } + + /** 退货从第一个计费日开始,每个阶梯只承担自己的天数。 */ + @Test + void accumulatesReturnTiersAtEveryBoundary() { + long[] days = {0, 1, 30, 31, 60, 61, 90, 91}; + String[] amounts = {"0", "1.5", "45", "47", "105", "108", "195", "199"}; + for (int i = 0; i < days.length; i++) + assertThat(StorageRentCalculator.unitAmount("RETURN", days[i])).as("第%d天", days[i]).isEqualByComparingTo(amounts[i]); + } + + /** 十箱两方分两次出库,余货使用原入库时刻和原数量分母。 */ + @Test + void pricesPartialTransitWithoutRestartingFreeDays() { + assertThat(amount("TRANSIT", 10, "2", "10", "4")).isEqualByComparingTo("1.92"); + assertThat(amount("TRANSIT", 31, "2", "10", "6")).isEqualByComparingTo("23.28"); + } + + /** 已进位的一托按原商品比例分摊,剩余六成不能再次进位成一托。 */ + @Test + void apportionsTheOriginalRoundedPalletAcrossShipments() { + assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("6"); + assertThat(amount("RETURN", 40, "1", "100", "60")).isEqualByComparingTo("39"); + var thirds = amount("RETURN", 10, "1", "3", "1"); + assertThat(thirds.multiply(new BigDecimal("3"))).isEqualByComparingTo("15"); + } + + /** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */ + @Test + void rejectsMissingMeasurementAndInvalidSourceQuantities() { + assertThatThrownBy(() -> amount("TRANSIT", 10, "0", "10", "1")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> amount("RETURN", 1, "1", "0", "1")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> amount("RETURN", 1, "1", "10", "11")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> StorageRentCalculator.days(inbound, inbound.minusNanos(1))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> StorageRentCalculator.unitAmount("UNKNOWN", 1)).isInstanceOf(IllegalArgumentException.class); + } + + /** 样例统一以原始数量计算出库份额。 */ + private BigDecimal amount(String rule, long days, String units, String original, String shipped) { + return StorageRentCalculator.amount(rule, inbound, inbound.plusDays(days), new BigDecimal(units), new BigDecimal(original), new BigDecimal(shipped)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLegacyReturnCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLegacyReturnCountTest.java new file mode 100644 index 000000000..49d15b194 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLegacyReturnCountTest.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.storagerent.entity.StorageRentLot; +import com.baiyue.lastleg.storagerent.entity.StorageRentShare; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import java.time.LocalDateTime; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 历史修复仅允许明确授权且凭证完整的原托,不能扩大为缺失计租批次的通用兜底。 */ +class StorageRentLegacyReturnCountTest { + private static final String RECEIPT_NO="RT-0KVUX7JR6C4JZ1I0QAOYVGIY2"; + private final StorageRentRepository repository=mock(StorageRentRepository.class); + private final StorageRentService service=new StorageRentService(); + private ReturnReceipt receipt; + private ReturnReceiptBatch batch; + private List boxes; + private List signedItems; + private final Map> boxItems=new HashMap<>(); + + /** 使用固定的测试签收时刻验证上海本地历史时间转 UTC,不读取或修改实际业务数据。 */ + @BeforeEach void setup(){ + ReflectionTestUtils.setField(service,"repository",repository); + ReflectionTestUtils.setField(service,"legacyReturnCountReceiptNos",RECEIPT_NO); + ReflectionTestUtils.setField(service,"legacyReturnTimeZone","Asia/Shanghai"); + receipt=new ReturnReceipt();receipt.setId(1L);receipt.setReturnOrderId(9L);receipt.setCustomerId("101");receipt.setReceiptNo(RECEIPT_NO); + receipt.setReceiptBatchNo("B-1");receipt.setUnitType("PALLET");receipt.setUnitCode("P-1");receipt.setPalletCode("P-1");receipt.setStatus("SIGNED"); + receipt.setReceivedQuantity(6L);receipt.setTotalBoxCount(3L);receipt.setSignedAt(LocalDateTime.of(2026,10,8,10,30)); + var order=new ReturnOrder();order.setId(9L);order.setCustomerId("101");order.setOverseasWarehouseId("21");order.setReturnNo("R-1"); + batch=new ReturnReceiptBatch();batch.setReceiptId(1L);batch.setReturnOrderId(9L);batch.setCustomerId("101");batch.setOverseasWarehouseId("21"); + batch.setBatchNo("B-1");batch.setStorageBillingStatus("PENDING_PRICING");batch.setStorageStartedAt(receipt.getSignedAt()); + signedItems=List.of(signedItem("A",4),signedItem("B",2)); + boxes=List.of(box(11,"A"),box(12,"A"),box(13,"B")); + when(repository.lockReturnReceipt(1L)).thenReturn(receipt);when(repository.returnOrder(9L)).thenReturn(order); + when(repository.returnReceiptBatches(1L)).thenAnswer(i->List.of(batch));when(repository.returnReceiptItems(1L)).thenAnswer(i->signedItems); + when(repository.receiptCartons(1L)).thenAnswer(i->boxes);when(repository.items(anyLong())).thenAnswer(i->boxItems.get(i.getArgument(0))); + } + + /** 重复调用复用唯一来源,恢复原托时间和数量,不在修复期间生成资金或租金。 */ + @Test void restoresOnlyOriginalLotAtActualHistoricalInstantAndIsIdempotent(){ + var saved=new ArrayList(); + doAnswer(i->{saved.add(i.getArgument(0));return null;}).when(repository).insert(any(StorageRentLot.class)); + when(repository.source("RETURN:1")).thenAnswer(i->saved.isEmpty()?null:saved.get(0)); + service.ensureReturnCountLot(1L);service.ensureReturnCountLot(1L); + assertThat(saved).hasSize(1);var lot=saved.get(0); + assertThat(lot.getInboundAt()).isEqualTo(LocalDateTime.of(2026,10,8,2,30));assertThat(lot.getCreatedAt()).isAfter(lot.getInboundAt()); + assertThat(lot.getOriginalUnits()).isEqualByComparingTo("1");assertThat(lot.getOriginalQuantity()).isEqualByComparingTo("6");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + assertThat(lot.getBusinessNo()).isEqualTo("R-1");assertThat(lot.getRuleCode()).isEqualTo("RETURN"); + assertThat(receipt.getSignedAt()).isEqualTo(LocalDateTime.of(2026,10,8,10,30)); + verify(repository,times(1)).lockReturnReceipt(1L);verify(repository,never()).insert(any(StorageRentShare.class)); + } + + /** 数量超过装箱 Long 缓存范围时仍按数值核对,避免引用比较误拒绝合法原托。 */ + @Test void largeQuantityUsesNumericEquality(){ + receipt.setReceivedQuantity(600L);signedItems=List.of(signedItem("A",400),signedItem("B",200)); + boxes.forEach(box->{box.setTotalQuantity(200L);boxItems.get(box.getId()).get(0).setQuantity(200L);}); + service.ensureReturnCountLot(1L);verify(repository).insert(any(StorageRentLot.class)); + } + + /** 默认不开放任何历史订单;白名单精确匹配,不按日期或任意新单自动恢复。 */ + @ParameterizedTest @ValueSource(strings={"", "OTHER-RECEIPT", "RT-0KVUX7JR6C4JZ1I0QAOYVGIY"}) + void unlistedMissingLotFailsClosed(String whitelist){ + ReflectionTestUtils.setField(service,"legacyReturnCountReceiptNos",whitelist); + assertThatThrownBy(()->service.ensureReturnCountLot(1L)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("缺少计租原批次"); + verify(repository,never()).insert(any(StorageRentLot.class)); + } + + /** 已有批次保持既有清点流程,不需要开启历史修复开关。 */ + @Test void existingLotDoesNotRequireLegacySettings(){ + ReflectionTestUtils.setField(service,"legacyReturnCountReceiptNos","");when(repository.source("RETURN:1")).thenReturn(new StorageRentLot()); + service.ensureReturnCountLot(1L);verify(repository,never()).lockReturnReceipt(anyLong());verify(repository,never()).insert(any(StorageRentLot.class)); + } + + /** 时间凭证和库存任一不完整时整体阻断,不能靠清点输入重造原始事实。 */ + @ParameterizedTest @ValueSource(strings={"timezone","signedAt","batchTime","missingBox","sku","counted","outbound","relabel","merged","reserved","sourceBatch"}) + void rejectsChangedOrIncompleteOriginalEvidence(String scenario){ + var box=boxes.get(0); + switch(scenario){ + case "timezone" -> ReflectionTestUtils.setField(service,"legacyReturnTimeZone",""); + case "signedAt" -> receipt.setSignedAt(null); + case "batchTime" -> batch.setStorageStartedAt(receipt.getSignedAt().plusHours(1)); + case "missingBox" -> boxes=boxes.subList(0,2); + case "sku" -> boxItems.get(box.getId()).get(0).setSkuCode("CHANGED"); + case "counted" -> box.setCountedAt(receipt.getSignedAt().plusHours(1)); + case "outbound" -> box.setStatus("SHIPPED"); + case "relabel" -> box.setRelabelApplicationId(99L); + case "merged" -> box.setMergedAt(receipt.getSignedAt().plusHours(1)); + case "reserved" -> boxItems.get(box.getId()).get(0).setPackingReservedQuantity(1L); + case "sourceBatch" -> box.setSourceBatchNo("OTHER"); + default -> throw new AssertionError(scenario); + } + assertThatThrownBy(()->service.ensureReturnCountLot(1L)).isInstanceOf(ResponseStatusException.class); + verify(repository,never()).insert(any(StorageRentLot.class)); + } + + /** 原始签收数量凭证。 */ + private ReturnReceiptItem signedItem(String sku,long quantity){var item=new ReturnReceiptItem();item.setCustomerId("101");item.setSkuCode(sku);item.setReceivedQuantity(quantity);return item;} + /** 每箱保留原托、原批次、原业务及尚未清点的库存快照。 */ + private ReturnInventoryCarton box(long id,String sku){ + var box=new ReturnInventoryCarton();box.setId(id);box.setCartonMark("BOX-"+id);box.setCustomerId("101");box.setOverseasWarehouseId("21"); + box.setSourceReceiptId(1L);box.setSourceBatchNo("B-1");box.setSourceReturnNo("R-1");box.setSourceUnitType("PALLET");box.setSourceUnitCode("P-1");box.setPalletCode("P-1"); + box.setCountStatus("PENDING");box.setStatus("AVAILABLE");box.setTotalQuantity(2L); + var item=new ReturnInventoryCartonItem();item.setCustomerId("101");item.setSkuCode(sku);item.setQuantity(2L);item.setPackingReservedQuantity(0L); + boxItems.put(id,List.of(item));return box; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLineageTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLineageTest.java new file mode 100644 index 000000000..e0736ddbf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentLineageTest.java @@ -0,0 +1,138 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 原托、合箱和换标始终保留原入库时间、基数与来源份额。 */ +class StorageRentLineageTest { + private final StorageRentRepository repository=mock(StorageRentRepository.class); + private final StorageRentService service=new StorageRentService(); + private final List shares=new ArrayList<>(); + private final Map lots=new HashMap<>(); + private final Map inboundLots=new HashMap<>(); + + /** 使用可变库存投影验证多轮正反操作的份额守恒。 */ + @BeforeEach void setup(){ + ReflectionTestUtils.setField(service,"repository",repository);ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules()); + when(repository.shares(anyString(),anyString(),anyString(),anyString())).thenAnswer(i->shares.stream().filter(s->s.getQuantity().signum()>0&&Objects.equals(s.getCartonMark(),i.getArgument(2))&&Objects.equals(s.getSkuCode(),i.getArgument(3))).sorted(Comparator.comparing(StorageRentShare::getLotId)).toList()); + when(repository.lockLot(anyLong())).thenAnswer(i->lots.get(i.getArgument(0))); + doAnswer(i->{shares.add(i.getArgument(0));return null;}).when(repository).insert(any(StorageRentShare.class)); + doAnswer(i->{StorageRentLot lot=i.getArgument(0);lots.put(lot.getId(),lot);return null;}).when(repository).insert(any(StorageRentLot.class)); + when(repository.lockSource(anyString())).thenAnswer(i->lots.values().stream().filter(l->Objects.equals(l.getSourceKey(),i.getArgument(0))).findFirst().orElse(null)); + when(repository.inboundRentLot(anyLong())).thenAnswer(i->inboundLots.get(i.getArgument(0))); + doAnswer(i->{inboundLots.put(i.getArgument(0),i.getArgument(1));return null;}).when(repository).recordInboundRent(anyLong(),anyLong(),any()); + when(repository.lockStockShare(anyLong(),anyLong(),anyString())).thenAnswer(i->shares.stream().filter(s->Objects.equals(s.getLotId(),i.getArgument(0))&&Objects.equals(s.getBalanceId(),i.getArgument(1))&&Objects.equals(s.getBatchNo(),i.getArgument(2))).findFirst().orElse(null)); + } + + /** 目标 SKU 已混有另一原托,退回仍只恢复本次转换的精确原托份额。 */ + @Test void skuConversionAndReversalPreserveExactOriginalLots(){ + addLot(1L,30);addLot(2L,10);addLot(3L,60); + shares.add(share(11L,1L,"BOX-1","OLD","4"));shares.add(share(12L,2L,"BOX-1","OLD","6"));shares.add(share(13L,3L,"BOX-1","NEW","20")); + var first=lots.get(1L).getInboundAt();var snapshot=service.convertReturnSkuShares("101","9","BOX-1","OLD","NEW",5); + assertThat(quantity(1L,"OLD")).isEqualByComparingTo("2");assertThat(quantity(2L,"OLD")).isEqualByComparingTo("3"); + assertThat(quantity(1L,"NEW")).isEqualByComparingTo("2");assertThat(quantity(2L,"NEW")).isEqualByComparingTo("3"); + service.reverseReturnSkuShares("101","9","BOX-1","OLD","NEW",snapshot); + assertThat(quantity(1L,"OLD")).isEqualByComparingTo("4");assertThat(quantity(2L,"OLD")).isEqualByComparingTo("6");assertThat(quantity(3L,"NEW")).isEqualByComparingTo("20"); + assertThat(quantity(1L,"NEW")).isZero();assertThat(quantity(2L,"NEW")).isZero();assertThat(lots.get(1L).getInboundAt()).isEqualTo(first); + verify(repository,never()).insert(any(StorageRentLot.class));verify(repository,never()).save(any(StorageRentLot.class)); + } + + /** 合箱只移动商品对应原托份额,不会生成新托或新的免费期。 */ + @Test void mergeSplitsEachOriginalLotProportionally(){ + addLot(1L,30);addLot(2L,10);shares.add(share(11L,1L,"SOURCE","SKU","4"));shares.add(share(12L,2L,"SOURCE","SKU","6")); + service.moveReturnShares("101","9","SOURCE","TARGET","SKU",5); + assertThat(shares.stream().filter(s->"TARGET".equals(s.getCartonMark())).map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("5"); + assertThat(quantity(1L,"SKU")).isEqualByComparingTo("4");assertThat(quantity(2L,"SKU")).isEqualByComparingTo("6"); + verify(repository,never()).insert(any(StorageRentLot.class)); + } + + /** 服务进程默认上海时区时,实际入库仍在计租事务独立记录 UTC,避免错移八小时。 */ + @Test void actualInboundRentClockDoesNotAssumeBusinessWallTimeIsUtc(){ + TimeZone old=TimeZone.getDefault();TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + try{ + var order=new LastlegOrder();order.setId(91L);order.setOrderNo("FIRST-1");order.setCustomerId("101");order.setOrderType("FIRST_LEG");order.setDestinationType("OVERSEAS");order.setOverseasWarehouseId("9");order.setVolumeCbm(new BigDecimal("2"));order.setPackageCount(10); + var receipt=new InboundStockReceipt();receipt.setId(101L);receipt.setInventoryBalanceId(21L);receipt.setReceivedPackages(4L);receipt.setBatchNo("B-1");receipt.setReceivedAt(LocalDateTime.now()); + when(repository.lockOrder(91L)).thenReturn(order); + var before=LocalDateTime.now(ZoneOffset.UTC);service.registerInbound(receipt,order);var after=LocalDateTime.now(ZoneOffset.UTC); + var captured=org.mockito.ArgumentCaptor.forClass(StorageRentLot.class);verify(repository).insert(captured.capture()); + var rent=captured.getValue();assertThat(rent.getInboundAt()).isBetween(before,after);assertThat(Duration.between(rent.getInboundAt(),receipt.getReceivedAt()).toHours()).isBetween(7L,8L); + assertThat(rent.getOriginalUnits()).isEqualByComparingTo("0.8");assertThat(rent.getRemainingQuantity()).isEqualByComparingTo("4"); + }finally{TimeZone.setDefault(old);} + } + + /** 实收重复回执不追加原计租基数。 */ + @Test void duplicateActualReceiptDoesNotCreateAnotherLot(){ + var order=new LastlegOrder();order.setOrderType("WAREHOUSE_TRANSFER"); + var receipt=new InboundStockReceipt();receipt.setId(101L);when(repository.source("INBOUND:101")).thenReturn(new StorageRentLot()); + service.registerInbound(receipt,order);verify(repository,never()).insert(any(StorageRentLot.class)); + } + + /** 头程多批补入库合并起点,部分出库后追加不能恢复旧出库数量或改变体积比例。 */ + @Test void firstLegReceiptsShareFirstClockAndKeepOutboundQuantity(){ + var order=firstLeg();var first=inbound(101,21,"B-1",4); + service.registerInbound(first,order);var lot=lots.values().iterator().next(); + var firstTime=LocalDateTime.now(ZoneOffset.UTC).minusDays(12);lot.setInboundAt(firstTime); + lot.setRemainingQuantity(new BigDecimal("1"));shares.get(0).setQuantity(BigDecimal.ONE); + order.setVolumeCbm(new BigDecimal("999")); + var second=inbound(102,22,"B-2",6);service.registerInbound(second,order); + assertThat(lots).hasSize(1);assertThat(lot.getInboundAt()).isEqualTo(firstTime); + assertThat(lot.getOriginalQuantity()).isEqualByComparingTo("10");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("7"); + assertThat(lot.getOriginalUnits()).isEqualByComparingTo("2");assertThat(shares).hasSize(2); + service.registerInbound(first,order);service.registerInbound(second,order); + assertThat(lot.getOriginalQuantity()).isEqualByComparingTo("10");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("7"); + assertThat(shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("7"); + } + + /** 同库存批次已出完时仍复用零份额,且补入库不重开免费期。 */ + @Test void firstLegTopUpReusesExhaustedShareAndDoesNotRestartClock(){ + var order=firstLeg();service.registerInbound(inbound(101,21,"B-1",4),order); + var lot=lots.values().iterator().next();var first=LocalDateTime.now(ZoneOffset.UTC).minusDays(20);lot.setInboundAt(first); + lot.setRemainingQuantity(BigDecimal.ZERO);shares.get(0).setQuantity(BigDecimal.ZERO); + service.registerInbound(inbound(102,21,"B-1",6),order); + assertThat(lots).hasSize(1);assertThat(shares).hasSize(1);assertThat(shares.get(0).getQuantity()).isEqualByComparingTo("6"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");assertThat(lot.getInboundAt()).isEqualTo(first); + verify(repository,times(2)).recordInboundRent(anyLong(),eq(lot.getId()),any()); + } + + /** 非头程分批实收各自保留实际入库时间,不合并为头程批次。 */ + @Test void otherInboundBatchesKeepIndependentClocks(){ + var order=firstLeg();order.setOrderType("WAREHOUSE_TRANSFER"); + service.registerInbound(inbound(101,21,"B-1",4),order);var first=lots.values().iterator().next(); + var earlier=LocalDateTime.now(ZoneOffset.UTC).minusDays(10);first.setInboundAt(earlier); + var before=LocalDateTime.now(ZoneOffset.UTC);service.registerInbound(inbound(102,21,"B-2",6),order); + assertThat(lots).hasSize(2);assertThat(first.getInboundAt()).isEqualTo(earlier); + assertThat(lots.values().stream().filter(l->l!=first).findFirst().orElseThrow().getInboundAt()).isAfterOrEqualTo(before); + verify(repository,never()).recordInboundRent(anyLong(),anyLong(),any()); + } + + /** 构造同单同仓、体积按箱均摊的头程订单。 */ + private LastlegOrder firstLeg(){var order=new LastlegOrder();order.setId(91L);order.setOrderNo("FIRST-1");order.setCustomerId("101");order.setOrderType("FIRST_LEG");order.setDestinationType("OVERSEAS");order.setOverseasWarehouseId("9");order.setVolumeCbm(new BigDecimal("2"));order.setPackageCount(10);when(repository.lockOrder(91L)).thenReturn(order);return order;} + /** 构造不同实收凭证,用于验证合批与凭证去重。 */ + private InboundStockReceipt inbound(long id,long balance,String batch,long quantity){var receipt=new InboundStockReceipt();receipt.setId(id);receipt.setInventoryBalanceId(balance);receipt.setBatchNo(batch);receipt.setReceivedPackages(quantity);receipt.setReceivedAt(LocalDateTime.now());return receipt;} + + /** 计租只覆盖明确自有海外仓头程、仓储转运,普通直送头程不纳入。 */ + @Test void eligibilityUsesExplicitDestinationAndBusinessType(){ + var order=new LastlegOrder();order.setOrderType("FIRST_LEG");order.setDestinationType("FBA");assertThat(StorageRentService.eligible(order)).isFalse(); + order.setDestinationType("OVERSEAS");assertThat(StorageRentService.eligible(order)).isTrue(); + order.setOrderType("WAREHOUSE_TRANSFER");order.setDestinationType(null);assertThat(StorageRentService.eligible(order)).isTrue(); + } + + /** 创建不同库龄的原托。 */ + private void addLot(long id,int days){var lot=new StorageRentLot();lot.setId(id);lot.setCustomerId("101");lot.setWarehouseId("9");lot.setInboundAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(days));lot.setOriginalUnits(BigDecimal.ONE);lots.put(id,lot);} + /** 创建同箱中某 SKU 的原托份额。 */ + private StorageRentShare share(long id,long lot,String carton,String sku,String quantity){var share=new StorageRentShare();share.setId(id);share.setLotId(lot);share.setCustomerId("101");share.setWarehouseId("9");share.setCartonMark(carton);share.setSkuCode(sku);share.setQuantity(new BigDecimal(quantity));return share;} + /** 同原托在所有当前箱中的总份额。 */ + private BigDecimal quantity(long lot,String sku){return shares.stream().filter(s->s.getLotId()==lot&&sku.equals(s.getSkuCode())).map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewSourceTest.java new file mode 100644 index 000000000..e4e4b2d81 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewSourceTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository; +import com.baiyue.lastleg.financefee.service.*; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class StorageRentReviewSourceTest { + /** 混合来源逐行追溯原计租单据,不用换标业务单覆盖,并隔离错误客户和仓库。 */ + @Test void describesOriginalSourcesForEachAllocationWithoutChangingAmounts() throws Exception { + var rents=mock(StorageRentRepository.class); + var json=new ObjectMapper().findAndRegisterModules(); + var service=new StorageRentReviewService(rents,mock(OverseasManualFeeRepository.class),mock(OverseasFeeSourceService.class), + mock(OverseasFeeCurrencyConverter.class),mock(WalletCommandOutboxService.class),json); + var at=LocalDateTime.of(2026,10,1,0,0); + var allocations=List.of(1L,2L,3L,4L,5L).stream().map(id->new Allocation(id,null,BigDecimal.ONE,new BigDecimal("2.00"),at,10)).toList(); + var plan=new Plan(new Owner("10","客户","RELABEL_ORDER",80L,"HB80",80L,null,"20"),allocations,at.plusDays(10),new BigDecimal("10.00")); + var rent=new StorageRentPreparation();rent.setId(90L);rent.setSourceFeeId(100L);rent.setWarehouseId("20"); + rent.setExecutionNo("OUT90");rent.setConsumedAt(at.plusDays(10));rent.setPlanJson(json.writeValueAsString(plan));rent.setStatus("PENDING_REVIEW"); + var fee=new OverseasFeeSource();fee.setSourceFeeId(100L);fee.setCreatedBy("STORAGE_RENT");fee.setFeeItemCode("FEE_TYPE_077"); + fee.setCustomerId("10");fee.setBusinessNo("HB80");fee.setAmount(new BigDecimal("70.00")); + when(rents.forFees(List.of(100L))).thenReturn(List.of(rent)); + when(rents.lots(List.of(1L,2L,3L,4L,5L))).thenReturn(List.of( + lot(1L,"10","20","LASTLEG_ORDER","WC1"),lot(2L,"10","20","RETURN_ORDER","TH2"), + lot(3L,"other","20","RETURN_ORDER","PRIVATE"),lot(4L,"10","other","RETURN_ORDER","OTHER-WAREHOUSE"))); + service.describe(List.of(fee)); + var lines=fee.getStorageRent().lines(); + assertThat(lines).extracting(OverseasFeeSource.RentLine::sourceBusinessNo).containsExactly("WC1","TH2",null,null,null); + assertThat(lines).extracting(OverseasFeeSource.RentLine::sourceBusinessType).containsExactly("LASTLEG_ORDER","RETURN_ORDER",null,null,null); + assertThat(lines.get(0).batchNo()).isEqualTo("B1"); + assertThat(fee.getBusinessNo()).isEqualTo("HB80"); + assertThat(fee.getAmount()).isEqualByComparingTo("70.00"); + assertThat(fee.getStorageRent().calculatedAmount()).isEqualByComparingTo("10.00"); + assertThat(fee.getStorageRent().chargedAmount()).isNull(); + verify(rents).lots(List.of(1L,2L,3L,4L,5L)); + } + + /** 构造原入库业务批次,不从现有出库订单推断来源。 */ + private StorageRentLot lot(long id,String customer,String warehouse,String type,String number) { + var lot=new StorageRentLot();lot.setId(id);lot.setCustomerId(customer);lot.setWarehouseId(warehouse); + lot.setBusinessType(type);lot.setBusinessNo(number);lot.setBatchNo("B"+id);return lot; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java new file mode 100644 index 000000000..b1cb66066 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java @@ -0,0 +1,470 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 真实资金协议和分批库存扣减的状态机回归。 */ +class StorageRentServiceTest { + private final StorageRentRepository repository=mock(StorageRentRepository.class); + private final StorageRentSourceResolver resolver=mock(StorageRentSourceResolver.class); + private final WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class); + private final OverseasFeeCurrencyConverter converter=mock(OverseasFeeCurrencyConverter.class); + private final OverseasFeeSourceService fees=mock(OverseasFeeSourceService.class); + private final StorageRentService service=new StorageRentService(); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private final PrepareRequest request=new PrepareRequest("OUTBOUND","TASK-1",11L,21L,4L,null,null,"scan-1",List.of(),null,null); + private final com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository feeRepo=mock(com.baiyue.lastleg.financefee.repository.OverseasManualFeeRepository.class); + private final StorageRentReviewService reviews=new StorageRentReviewService(repository,feeRepo,fees,converter,wallet,json); + private OverseasFeeSource rentFee; + private com.baiyue.lastleg.financefee.entity.OverseasFeeFunding funding; + private StorageRentPreparation current; + private StorageRentLot lot; + private StorageRentShare share; + private Plan plan; + private long eventSequence; + + /** 同一扫码保留插入的不可变预占快照,模拟持久化边界。 */ + @BeforeEach void setup() { + ReflectionTestUtils.setField(service,"repository",repository);ReflectionTestUtils.setField(service,"resolver",resolver); + ReflectionTestUtils.setField(service,"wallet",wallet);ReflectionTestUtils.setField(service,"converter",converter); + ReflectionTestUtils.setField(service,"feeSources",fees);ReflectionTestUtils.setField(service,"json",json); + ReflectionTestUtils.setField(service,"rentReview",reviews); + var commands=new HashMap(); + when(feeRepo.command(anyString(),anyString(),anyString())).thenAnswer(i->commands.get(i.getArgument(0)+":"+i.getArgument(1)+":"+i.getArgument(2))); + doAnswer(i->{var c=(com.baiyue.lastleg.financefee.entity.OverseasFeeCommand)i.getArgument(0);commands.put(c.getOperatorId()+":"+c.getAction()+":"+c.getIdempotencyKey(),c);return null;}).when(feeRepo).insertCommand(any()); + when(feeRepo.lockFee(anyLong())).thenAnswer(i->rentFee); + when(feeRepo.funding(anyLong())).thenAnswer(i->funding); + when(feeRepo.lockFunding(anyLong())).thenAnswer(i->funding); + doAnswer(i->{funding=i.getArgument(0);return null;}).when(feeRepo).insertFunding(any()); + doAnswer(i->{rentFee=i.getArgument(0);return null;}).when(feeRepo).insertFee(any()); + when(fees.createPendingStorageRent(anyString(),any(),any())).thenAnswer(i->{ + var r=(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest)i.getArgument(1); + var c=(OverseasFeeCurrencyConverter.Conversion)i.getArgument(2); + rentFee=new OverseasFeeSource();rentFee.setId(702L);rentFee.setSourceFeeId(701L);rentFee.setSourceVersion(1); + rentFee.setCustomerId("101");rentFee.setCreatedBy("STORAGE_RENT");rentFee.setFeeItemCode("FEE_TYPE_077"); + rentFee.setReviewStatus("PENDING");rentFee.setSourceStatus("ACTIVE");rentFee.setPublicationStatus("UNPUBLISHED"); + rentFee.setBusinessType(r.businessType());rentFee.setOriginalCurrency(c.originalCurrency());rentFee.setOriginalAmount(c.originalAmount()); + rentFee.setAmount(c.settlementAmount());rentFee.setExchangeRate(c.exchangeRate());rentFee.setBusinessDate(r.businessDate());return rentFee; + }); + var at=LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1); + plan=new Plan(new Owner("101","客户","LASTLEG_ORDER",91L,"FIRST-1",91L,"IN-1","9"), + List.of(new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("1.92"),at.minusDays(10),10)),at,new BigDecimal("1.92")); + when(resolver.resolve(eq("9"),any(),any())).thenAnswer(i->plan); + when(resolver.resolve(eq("9"),any(),any(),any())).thenAnswer(i->plan); + when(repository.latest(anyString(),anyString(),anyString())).thenAnswer(i->current); + doAnswer(i->{current=i.getArgument(0);return null;}).when(repository).insert(any(StorageRentPreparation.class)); + when(repository.lockPreparation(anyLong())).thenAnswer(i->current); + when(converter.convert(eq("USD"),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion("USD",i.getArgument(1),new BigDecimal("7.123456"),plan.calculatedAt().toString().substring(0,7),((BigDecimal)i.getArgument(1)).multiply(new BigDecimal("7.123456")).setScale(2,java.math.RoundingMode.HALF_UP))); + when(wallet.publish(anyString(),eq(101L),eq("STORAGE_RENT"),anyString(),any(),anyString(),anyLong())).thenAnswer(i->i.getArgument(0)+"-"+(++eventSequence)); + lot=new StorageRentLot();lot.setId(51L);lot.setCustomerId("101");lot.setWarehouseId("9");lot.setOriginalQuantity(BigDecimal.TEN);lot.setRemainingQuantity(BigDecimal.TEN); + share=new StorageRentShare();share.setId(61L);share.setLotId(51L);share.setQuantity(BigDecimal.TEN); + when(repository.lockLot(51L)).thenReturn(lot);when(repository.lockShare(61L)).thenReturn(share); + var fee=new OverseasFeeSource();fee.setSourceFeeId(701L);when(fees.publishSettled(anyString(),anyString(),any(),any(),any())).thenReturn(fee); + } + + /** 未收到成功冻结回执时,原库存与计租份额都不能扣减。 */ + @Test void pendingOrFailedFreezeCannotConsumeInventory() { + assertThat(service.prepare("9",request).ready()).isFalse(); + assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("未冻结成功"); + service.handle(completed("FREEZE",false,null,null,null)); + assertThat(current.getStatus()).isEqualTo("FAILED"); + assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("未冻结成功"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");verify(repository,never()).save(any(StorageRentLot.class));verifyNoInteractions(fees); + } + + /** 先冻结再出库,仓租仅记实际份额,财务回执前不发布已收费用。 */ + @Test void confirmedFreezeConsumesOnlyActualSharesAndPublishesAfterSettlement() { + ready();service.consume("9",request); + assertThat(current.getStatus()).isEqualTo("PENDING_REVIEW");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + assertThat(share.getQuantity()).isEqualByComparingTo("6");assertThat(rentFee.getReviewStatus()).isEqualTo("PENDING"); + verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + approve();var event=completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002");service.handle(event);service.handle(event); + assertThat(current.getStatus()).isEqualTo("SETTLED");assertThat(rentFee.getWalletSettlement().ledgerId()).isEqualTo("8002"); + verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());verify(fees,never()).publishSettled(anyString(),anyString(),any(),any(),any()); + } + + /** 使用真实费用校验验证扣款回执只登记本地明细,重放不重复扣款或推送结算。 */ + @ParameterizedTest @ValueSource(strings={"LASTLEG_ORDER","RETURN_ORDER","WAREHOUSE_TRANSFER","RELABEL_ORDER"}) + void eachBusinessCreatesPendingRentBeforeApproval(String businessType) { + var o=plan.owner();plan=new Plan(new Owner(o.customerId(),o.customerName(),businessType,o.businessId(),o.businessNo(),o.orderId(),o.inboundCode(),o.warehouseId()),plan.allocations(),plan.calculatedAt(),plan.amount(),plan.pricing()); + ready();service.consume("9",request); + assertThat(rentFee.getBusinessType()).isEqualTo(businessType);assertThat(rentFee.getReviewStatus()).isEqualTo("PENDING"); + assertThat(rentFee.getWalletSettlement()).isNull();assertThat(current.getSourceFeeId()).isEqualTo(701L); + verify(wallet,times(1)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + + /** 冻结请求重发复用同一尝试和金额,不重复占用客户余额授信。 */ + @Test void preparationReplayDoesNotFreezeTwice() { + var first=service.prepare("9",request);var replay=service.prepare("9",request); + assertThat(replay.preparationId()).isEqualTo(first.preparationId()); + verify(wallet,times(1)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + assertThatThrownBy(()->service.prepare("9",new PrepareRequest("OUTBOUND","TASK-1",11L,21L,5L,null,null,"scan-1",List.of(),null,null))).hasMessageContaining("不同的货物或数量"); + } + + /** 改价后有效重复和消费仍传原配置快照,不改变冻结金额或版本。 */ + @Test void activePreparationAndConsumeUseSavedPricingAfterConfigurationChanges() throws Exception { + ready();var snapshot=json.readValue(current.getPlanJson(),Plan.class).pricing();var amount=current.getAmount();clearInvocations(resolver,converter); + var saved=plan;when(resolver.resolve(eq("9"),any(),any(),any())).thenAnswer(i->{assertThat((Pricing)i.getArgument(3)).isEqualTo(snapshot);return saved;}); + when(resolver.resolve(eq("9"),any(),any())).thenThrow(new IllegalStateException("新报价不应读取")); + service.prepare("9",request);service.consume("9",request); + verify(resolver,times(2)).resolve(eq("9"),eq(request),any(),eq(snapshot));verify(resolver,never()).resolve(anyString(),any(),any()); + assertThat(current.getAmount()).isEqualByComparingTo(amount);verifyNoInteractions(converter); + } + + /** 跨入新计租日使原预占失效,真正重建时才读取最新配置重新冻结。 */ + @Test void changedAgeRebuildReadsCurrentPricingRatherThanPreviousSnapshot(){ + ready();var first=current;var a=plan.allocations().get(0);var changed=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),new BigDecimal("2.56"),a.inboundAt(),11)),plan.calculatedAt(),new BigDecimal("2.56")); + when(resolver.resolve(eq("9"),any(),any(),any())).thenReturn(changed);plan=changed;clearInvocations(resolver); + service.prepare("9",request);assertThat(current.getId()).isNotEqualTo(first.getId());assertThat(first.getStatus()).isEqualTo("EXPIRED"); + verify(resolver).resolve(eq("9"),eq(request),any(),any());verify(resolver).resolve(eq("9"),eq(request),any()); + } + + /** 人民币配置不误用美元汇率,资金、财务来源和详情保持实际原币。 */ + @Test void cnyPricingKeepsCurrencyAcrossFundingSettlementAndDetails(){ + var defaults=StorageRentCalculator.defaults(101L);plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),plan.amount(),new Pricing("31",101L,2,"CNY",defaults.tiers(),"CUSTOMER")); + when(converter.convert(eq("CNY"),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion("CNY",i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1))); + ready();service.consume("9",request);approve();service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002")); + assertThat(rentFee.getOriginalCurrency()).isEqualTo("CNY");assertThat(rentFee.getAmount()).isEqualByComparingTo(rentFee.getOriginalAmount()); + verify(converter,never()).convert(eq("USD"),any(),any()); + } + + /** 历次出库配置切换币种时,列表和详情按币种汇总,不把美元和人民币直接相加。 */ + @Test void mixedCurrenciesRemainSeparatedInListAndDetail() throws Exception { + var usd=new StorageRentPreparation();usd.setId(1L);usd.setOriginalAmount(new BigDecimal("1.92"));usd.setAmount(new BigDecimal("13.68"));usd.setPlanJson(json.writeValueAsString(plan)); + var pricing=plan.pricing();var cny=new StorageRentPreparation();cny.setId(2L);cny.setOriginalAmount(new BigDecimal("1.92"));cny.setAmount(new BigDecimal("1.92"));cny.setPlanJson(json.writeValueAsString(new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),plan.amount(),new Pricing("31",101L,2,"CNY",pricing.tiers(),"CUSTOMER")))); + lot.setInboundAt(plan.allocations().get(0).inboundAt());lot.setRuleCode("TRANSIT");when(repository.lot(51L)).thenReturn(lot);when(repository.charges(51L)).thenReturn(List.of(usd,cny)); + var page=new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1,20);page.setRecords(List.of(lot));page.setTotal(1);when(repository.page(any(),eq(1L),eq(20L))).thenReturn(page);when(repository.charges(List.of(51L))).thenReturn(List.of(usd,cny)); + for(var view:List.of(service.detail(51L).lot(),service.list(new Query(null,null,null,null,null,1L,20L)).records().get(0))){assertThat(view.generatedAmount()).isNull();assertThat(view.generatedCurrency()).isNull();assertThat(view.generatedAmounts()).containsExactly(new CurrencyAmount("CNY",new BigDecimal("1.92")),new CurrencyAmount("USD",new BigDecimal("1.92")));} + } + + /** 已扣减请求仅恢复原扫码结果,不能再次扣减剩余批次。 */ + @Test void consumedPreparationReplayReturnsOriginalSnapshot() { + ready();service.consume("9",request);clearInvocations(resolver); + assertThat(service.prepare("9",request).status()).isEqualTo("CONSUMED");verifyNoInteractions(resolver); + assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("已扣减"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + } + + /** Finance 失败回执不携带 holdId,必须落库失败态并用新版本恢复。 */ + @Test void failedSettleWithNullHoldRetriesWithoutDecrementingAgain() { + ready();service.consume("9",request);approve();String old=current.getSettleEventId(); + service.handle(completed("SETTLE_ACTUAL",false,null,null,null)); + assertThat(current.getStatus()).isEqualTo("SETTLE_FAILED");assertThat(current.getHoldId()).isEqualTo("8001"); + current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId()); + assertThat(current.getStatus()).isEqualTo("CONSUMED");assertThat(current.getSettleEventId()).isNotEqualTo(old); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any()); + service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));assertThat(current.getStatus()).isEqualTo("SETTLED"); + } + + /** 未知扣款状态不能重发新版本,避免网络迟到导致第二次资金动作。 */ + @Test void consumedWithoutCallbackIsNotBlindlyRetried() { + ready();service.consume("9",request);clearInvocations(wallet); + current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId());verifyNoInteractions(wallet); + } + + /** 取消冻结中的扫码,迟到成功回执必须立即补发解冻。 */ + @Test void cancelledPendingFreezeReleasesWhenSuccessArrivesLate() { + service.prepare("9",request);service.cancel("9",current.getId()); + assertThat(current.getStatus()).isEqualTo("CANCELLED");assertThat(current.getReleaseEventId()).isNull(); + service.handle(completed("FREEZE",true,"FROZEN","8001",null)); + assertThat(current.getReleaseEventId()).isNotBlank();assertThat(current.getReleaseStatus()).isEqualTo("PENDING"); + assertThat(service.preparation("9",current.getId()).ready()).isFalse();verifyNoInteractions(fees); + } + + /** 解冻失败保留原冻结身份,恢复任务重发且不产生应收。 */ + @Test void failedReleaseWithNullHoldCanRecover() { + ready();service.cancel("9",current.getId());String first=current.getReleaseEventId(); + service.handle(completed("RELEASE",false,null,null,null));assertThat(current.getReleaseStatus()).isEqualTo("FAILED"); + current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId()); + assertThat(current.getReleaseEventId()).isNotEqualTo(first);assertThat(current.getReleaseStatus()).isEqualTo("PENDING"); + service.handle(completed("RELEASE",true,"RELEASED","8001",null));assertThat(current.getReleaseStatus()).isEqualTo("SUCCEEDED");verifyNoInteractions(fees); + } + + /** 空结果、未知结果或缺失完成时间不能转成可出库状态。 */ + @Test void invalidResultsCannotAdvanceFundingState() { + service.prepare("9",request); + for(String result:Arrays.asList(null,"PENDING","SUCCEEDED")) { + var event=new WalletCommandCompletedV1(current.getFreezeEventId(),"FREEZE",101L,"STORAGE_RENT",current.getId().toString(),result,"FROZEN",current.getAmount(),null,null,null,"8001",null); + assertThatThrownBy(()->service.handle(event)).hasMessageContaining("结果不完整"); + } + assertThat(current.getStatus()).isEqualTo("FREEZE_PENDING"); + } + + /** 回执必须属于原客户、金额和指令,不能用其他预占凭据冒充已收。 */ + @Test void foreignCallbackIdentityCannotUnlockScan() { + service.prepare("9",request); + var valid=completed("FREEZE",true,"FROZEN","8001",null); + assertThatThrownBy(()->service.handle(new WalletCommandCompletedV1("other","FREEZE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","FROZEN",current.getAmount(),null,null,Instant.now(),"8001",null))).hasMessageContaining("指令不一致"); + assertThatThrownBy(()->service.handle(new WalletCommandCompletedV1(valid.commandEventId(),"FREEZE",102L,"STORAGE_RENT",valid.businessNo(),"SUCCEEDED","FROZEN",current.getAmount(),null,null,Instant.now(),"8001",null))).hasMessageContaining("客户或金额不一致"); + assertThatThrownBy(()->service.handle(new WalletCommandCompletedV1(valid.commandEventId(),"FREEZE",101L,"STORAGE_RENT",valid.businessNo(),"SUCCEEDED","FROZEN",BigDecimal.ONE,null,null,Instant.now(),"8001",null))).hasMessageContaining("客户或金额不一致"); + assertThatThrownBy(()->service.handle(completed("FREEZE",true,"FROZEN",null,null))).hasMessageContaining("资金占用凭据"); + } + + /** PDA 预占超过有效期后即使曾成功冻结也不能继续扣库存。 */ + @Test void expiredReadyPreparationBlocksScanAndReleasesFunds() { + ready();current.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(16)); + assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("预占已过期"); + service.recover(current.getId());assertThat(current.getStatus()).isEqualTo("EXPIRED");assertThat(current.getReleaseEventId()).isNotBlank(); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10"); + } + + /** 非零收费必须具备真实月度汇率,换算失败不能持久化资金预占。 */ + @Test void paidRentCannotUseFallbackExchangeRate() { + when(converter.convert(anyString(),any(),any())).thenThrow(new IllegalStateException("缺少已确认汇率")); + assertThatThrownBy(()->service.prepare("9",request)).hasMessageContaining("汇率");assertThat(current).isNull();verifyNoInteractions(wallet); + } + + /** 免费期无汇率仍允许实物出库,并保留零仓租记录和份额扣减。 */ + @Test void freeStorageNeedsNoRateOrWalletButStillTracksActualOutbound() { + var a=plan.allocations().get(0);plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),BigDecimal.ZERO,a.inboundAt(),7)),plan.calculatedAt(),BigDecimal.ZERO); + assertThat(service.prepare("9",request).ready()).isTrue();assertThat(current.getExchangeRate()).isNull(); + current.setExchangeRateMonth("2000-01");service.consume("9",request); + assertThat(current.getStatus()).isEqualTo("PENDING_REVIEW");assertThat(current.getConsumedAt()).isNotNull();assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + verifyNoInteractions(converter,wallet);assertThat(rentFee.getAmount()).isZero(); + } + + /** 没有计租来源的非适用业务不建立孤立零费用记录。 */ + @Test void nonRentCargoDoesNotCreateFunding() { + plan=new Plan(plan.owner(),List.of(),plan.calculatedAt(),BigDecimal.ZERO); + assertThat(service.prepare("9",request).status()).isEqualTo("NOT_REQUIRED");service.consume("9",request); + assertThat(current).isNull();verifyNoInteractions(wallet,converter,fees); + } + + /** 换标后的原来源仍属于计租货物,管理端旧直接出库路径不得绕过。 */ + @Test void existingRentSharesBlockLegacyDirectStockoutEvenAfterOrderTypeChanged() { + var position=new InventoryPosition();position.setCustomerId("101");position.setOverseasWarehouseId("9");position.setBalanceId(71L);position.setBatchNo("B-1"); + when(repository.position(21L)).thenReturn(position);when(repository.balance(71L)).thenReturn(new InventoryBalance()); + when(repository.stockShares("101","9",71L,"B-1")).thenReturn(List.of(share)); + assertThatThrownBy(()->service.requirePdaOutbound("101","9",21L)).hasMessageContaining("PDA"); + } + + /** 查询和取消不能从当前绑定仓访问其他仓的客户资金状态。 */ + @Test void preparationAccessIsWarehouseScoped() { + service.prepare("9",request);assertThatThrownBy(()->service.preparation("10",current.getId())).hasMessageContaining("当前仓没有"); + assertThatThrownBy(()->service.cancel("10",current.getId())).hasMessageContaining("当前仓没有"); + } + + /** 清仓库龄按最后实际出库停止,列表与详情不会受后续更新时间影响。 */ + @Test void clearedLotStopsAgeAtActualLastOutboundAndExposesUtcOffset() throws Exception { + lot.setInboundAt(LocalDateTime.of(2026,1,1,0,0));lot.setRemainingQuantity(BigDecimal.ZERO);lot.setUpdatedAt(LocalDateTime.of(2026,10,1,0,0));lot.setRuleCode("TRANSIT"); + var preparation=new StorageRentPreparation();preparation.setId(31L);preparation.setCreatedAt(LocalDateTime.of(2026,1,8,0,0));preparation.setConsumedAt(LocalDateTime.of(2026,1,9,0,0));preparation.setStatus("SETTLED"); + preparation.setOriginalAmount(plan.amount());preparation.setAmount(new BigDecimal("13.68"));preparation.setPlanJson(json.writeValueAsString(plan)); + when(repository.lot(51L)).thenReturn(lot);when(repository.charges(51L)).thenReturn(List.of(preparation)); + var page=new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1,20);page.setRecords(List.of(lot));page.setTotal(1); + when(repository.page(any(),eq(1L),eq(20L))).thenReturn(page);when(repository.charges(List.of(51L))).thenReturn(List.of(preparation)); + var detail=service.detail(51L);var listed=service.list(new Query(null,null,null,null,null,1L,20L)).records().get(0); + assertThat(detail.lot().storageDays()).isEqualTo(8);assertThat(listed.storageDays()).isEqualTo(8); + assertThat(json.writer().without(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).writeValueAsString(detail)).contains("2026-01-01T00:00:00Z").contains("2026-01-09T00:00:00Z"); + } + + /** 多批次同次出库的分币尾差只记一次,原币及人民币明细都能与冻结总额核平。 */ + @Test void mixedLotDetailAmountsReconcileToExactFundingTotal() throws Exception { + var a=plan.allocations().get(0);var mixed=new Plan(plan.owner(),List.of(new Allocation(51L,61L,BigDecimal.ONE,new BigDecimal("0.005"),a.inboundAt(),10),new Allocation(52L,62L,BigDecimal.ONE,new BigDecimal("0.005"),a.inboundAt(),10)),plan.calculatedAt(),new BigDecimal("0.01")); + var value=new StorageRentPreparation();value.setId(31L);value.setOriginalAmount(new BigDecimal("0.01"));value.setAmount(new BigDecimal("0.07"));value.setPlanJson(json.writeValueAsString(mixed)); + lot.setInboundAt(a.inboundAt());lot.setRuleCode("TRANSIT");var other=new StorageRentLot();other.setId(52L);other.setInboundAt(a.inboundAt());other.setRemainingQuantity(BigDecimal.TEN);other.setRuleCode("TRANSIT"); + when(repository.lot(51L)).thenReturn(lot);when(repository.lot(52L)).thenReturn(other);when(repository.charges(51L)).thenReturn(List.of(value));when(repository.charges(52L)).thenReturn(List.of(value)); + var first=service.detail(51L).charges().get(0);var second=service.detail(52L).charges().get(0); + assertThat(first.originalAmount().add(second.originalAmount())).isEqualByComparingTo("0.01");assertThat(first.settlementAmount().add(second.settlementAmount())).isEqualByComparingTo("0.07"); + assertThat(first.originalAmount()).isNotNegative();assertThat(second.originalAmount()).isNotNegative(); + } + + /** 原托多次出库累计原币仓租,仅成功结算计入扣款,列表与详情保持一致。 */ + @Test void originalLotShowsSettledAndPendingChargesSeparately() throws Exception { + lot.setInboundAt(plan.allocations().get(0).inboundAt());lot.setRuleCode("RETURN"); + var values=new ArrayList(); + for(int i=0;i<3;i++){ + var value=new StorageRentPreparation();value.setId(31L+i);value.setStatus(i<2?"SETTLED":"SETTLE_FAILED"); + value.setOriginalAmount(new BigDecimal("0.75"));value.setAmount(new BigDecimal("5.00")); + value.setConsumedAt(plan.calculatedAt());value.setPlanJson(json.writeValueAsString(plan));values.add(value); + } + when(repository.lot(51L)).thenReturn(lot);when(repository.charges(51L)).thenReturn(values); + var page=new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1,20);page.setRecords(List.of(lot));page.setTotal(1); + when(repository.page(any(),eq(1L),eq(20L))).thenReturn(page);when(repository.charges(List.of(51L))).thenReturn(values); + for(var view:List.of(service.detail(51L).lot(),service.list(new Query(null,null,null,null,null,1L,20L)).records().get(0))){ + assertThat(view.generatedAmount()).isEqualByComparingTo("2.25");assertThat(view.settledAmount()).isEqualByComparingTo("10.00"); + assertThat(view.chargeCount()).isEqualTo(3);assertThat(view.settledChargeCount()).isEqualTo(2); + } + } + + /** 审核前调价必须留说明,版本保留原计算快照,重复审核不再发扣款。 */ + @Test void adjustmentPreservesOriginalBasisAndReviewIsIdempotent() throws Exception { + ready();var initialFreeze=completed("FREEZE",true,"FROZEN","8001",null);service.consume("9",request); + var old=rentFee;String snapshot=current.getPlanJson(); + assertThatThrownBy(()->reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("2.00")," ","adjust-1"))).hasMessageContaining("说明"); + var req=new AdjustRentRequest(1,new BigDecimal("2.00"),"人工核对增加仓租","adjust-1");reviews.adjust(701L,"operator",req);reviews.adjust(701L,"operator",req); + assertThat(old.getSourceStatus()).isEqualTo("SUPERSEDED");assertThat(rentFee.getSourceVersion()).isEqualTo(2); + assertThat(rentFee.getFeeRemark()).isEqualTo("人工核对增加仓租");assertThat(rentFee.getAmount()).isEqualByComparingTo("14.25"); + assertThat(current.getPlanJson()).isEqualTo(snapshot);service.handle(initialFreeze); + assertThatThrownBy(()->reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("APPROVE",null,"stale-review",1))).hasMessageContaining("版本已变化"); + approve();approve();verify(wallet,times(1)).publish(eq("SETTLE_ACTUAL"),eq(101L),eq("STORAGE_RENT"),anyString(),eq(new BigDecimal("14.25")),anyString(),anyLong()); + verify(feeRepo,times(1)).insertFee(any()); + assertThatThrownBy(()->reviews.adjust(701L,"operator",new AdjustRentRequest(2,BigDecimal.ONE,"审核后","adjust-2"))).hasMessageContaining("待审核"); + } + /** 调低费用使用实际金额结算,失败保留原冻结,不重复库存扣减。 */ + @Test void lowerAdjustedAmountSettlesAgainstOriginalHoldAndFailureRetainsIt(){ + ready();service.consume("9",request);reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对减免","adjust-1"));approve(); + service.handle(completed("SETTLE_ACTUAL",false,null,null,null));assertThat(current.getStatus()).isEqualTo("SETTLE_FAILED"); + assertThat(current.getHoldId()).isEqualTo("8001");assertThat(rentFee.getWalletSettlement()).isNull(); + current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId()); + service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));assertThat(current.getStatus()).isEqualTo("SETTLED"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + } + /** 调高后资金不足保留原冻结,后续重试只结算确认金额、不重复扣库存。 */ + @Test void increasedRentFailureKeepsOriginalHoldAndRetriesConfirmedAmountOnly(){ + ready();service.consume("9",request);var originalHold=funding.getCommandHistory().get("holdAmount"); + reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("20.00"),"补收漏算仓租","increase-1"));approve(); + var failed=completed("SETTLE_ACTUAL",false,null,null,null);service.handle(failed);service.handle(failed); + assertThat(current.getStatus()).isEqualTo("SETTLE_FAILED");assertThat(current.getHoldId()).isEqualTo("8001"); + assertThat(funding.getCommandHistory().get("holdAmount")).isEqualTo(originalHold); + assertThat(rentFee.getPublicationStatus()).isEqualTo("UNPUBLISHED");assertThat(rentFee.getWalletSettlement()).isNull(); + current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId()); + service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002")); + assertThat(current.getAmount()).isEqualByComparingTo("142.47");assertThat(current.getStatus()).isEqualTo("SETTLED"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");assertThat(share.getQuantity()).isEqualByComparingTo("6"); + verify(wallet,times(2)).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),eq(new BigDecimal("142.47")),anyString(),anyLong()); + verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any()); + } + + /** 调价与审核同键不可携带不同内容,避免重试覆盖已经确认的金额或决定。 */ + @Test void adjustmentAndReviewRejectReusedKeysWithDifferentContents(){ + ready();service.consume("9",request); + reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对减免","adjust-1")); + assertThatThrownBy(()->reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.TEN,"改成十元","adjust-1"))).hasMessageContaining("不同仓租请求"); + approve(); + assertThatThrownBy(()->reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT","撤销审核","review-1",2))).hasMessageContaining("不同仓租请求"); + assertThat(rentFee.getReviewStatus()).isEqualTo("APPROVED");assertThat(current.getOriginalAmount()).isEqualByComparingTo("1"); + verify(wallet,times(1)).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + + /** 资金回执必须绑定原冻结和实扣金额,伪造的成功不能进入已收费用。 */ + @Test void successfulSettlementRejectsWrongAmountOrHold(){ + ready();service.consume("9",request);approve(); + var wrongAmount=new WalletCommandCompletedV1(current.getSettleEventId(),"SETTLE_ACTUAL",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","SETTLED",BigDecimal.ONE,null,null,Instant.now(),"8001","8002"); + assertThatThrownBy(()->service.handle(wrongAmount)).hasMessageContaining("金额不一致"); + assertThatThrownBy(()->service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","other-hold","8002"))).hasMessageContaining("冻结凭据不一致"); + assertThat(current.getStatus()).isEqualTo("CONSUMED");assertThat(rentFee.getWalletSettlement()).isNull(); + assertThat(rentFee.getPublicationStatus()).isEqualTo("UNPUBLISHED"); + } + + /** 调价增加后驳回仍按原冻结额释放,不按增加后的应收释放。 */ + @Test void rejectedIncreaseReleasesOriginalHoldAmountAndRecoversOnlyRelease(){ + ready();var held=current.getAmount();service.consume("9",request); + reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("20.00"),"补算","increase-1")); + reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT","不应收费","reject-1",2)); + service.handle(completed("RELEASE",false,null,null,null)); + current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId()); + var released=new WalletCommandCompletedV1(current.getReleaseEventId(),"RELEASE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","RELEASED",held,null,null,Instant.now(),"8001",null); + service.handle(released);service.handle(released); + assertThat(current.getStatus()).isEqualTo("REJECTED");assertThat(funding.getWalletStatus()).isEqualTo("RELEASED"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + verify(wallet,times(2)).publish(eq("RELEASE"),anyLong(),anyString(),anyString(),isNull(),anyString(),anyLong()); + verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + + /** 调成零元审核后只释放原占用,回执必须核对原冻结金额。 */ + @Test void zeroAdjustmentReleasesOriginalAmountOnlyAfterApproval(){ + ready();var held=current.getAmount();service.consume("9",request); + reviews.adjust(701L,"operator",new AdjustRentRequest(1,new BigDecimal("0.00"),"免除仓租","adjust-1")); + verify(wallet,never()).publish(eq("RELEASE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong());approve(); + var result=new WalletCommandCompletedV1(current.getReleaseEventId(),"RELEASE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","RELEASED",held,null,null,Instant.now(),"8001",null); + service.handle(result);service.handle(result);assertThat(current.getStatus()).isEqualTo("SETTLED");assertThat(rentFee.getWalletSettlement()).isNull(); + verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + /** 驳回说明必填,释放资金后仍保留已出库计费记录。 */ + @Test void rejectedRentReleasesHoldWithoutRepeatingStock(){ + ready();service.consume("9",request); + assertThatThrownBy(()->reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT"," ","reject-1",1))).hasMessageContaining("原因"); + var req=new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("REJECT","费用有误","reject-1",1);reviews.review(701L,"operator","审核员",req);reviews.review(701L,"operator","审核员",req); + service.handle(completed("RELEASE",true,"RELEASED","8001",null));assertThat(current.getStatus()).isEqualTo("REJECTED");assertThat(funding.getWalletStatus()).isEqualTo("RELEASED"); + assertThat(service.prepare("9",request).status()).isEqualTo("CONSUMED");verify(wallet,times(1)).publish(eq("RELEASE"),anyLong(),anyString(),anyString(),isNull(),anyString(),anyLong()); + } + /** 免费出库调整收费先补冻结,冻结成功以前不开放审核。 */ + @Test void zeroToPositiveAdjustmentWaitsForNewFreeze(){ + plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),BigDecimal.ZERO);service.prepare("9",request);service.consume("9",request); + reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对补收","adjust-1"));assertThat(current.getStatus()).isEqualTo("REVIEW_FREEZE_PENDING"); + assertThatThrownBy(this::approve).hasMessageContaining("尚未锁定"); + service.handle(completed("FREEZE",true,"FROZEN","8001",null));approve();assertThat(current.getStatus()).isEqualTo("CONSUMED"); + } + /** 跨计租日必须重新预校验,不可使用前一天的冻结放行库存。 */ + @Test void changedChargeDayCannotConsumePreviousFreeze(){ + ready();var original=plan.allocations().get(0); + plan=new Plan(plan.owner(),List.of(new Allocation(original.lotId(),original.shareId(),original.quantity(),original.amount().add(BigDecimal.ONE),original.inboundAt(),original.days()+1)),plan.calculatedAt().plusDays(1),plan.amount().add(BigDecimal.ONE),plan.pricing()); + assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("计费时间或货物已变化"); + assertThat(current.getConsumedAt()).isNull();assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10"); + assertThat(share.getQuantity()).isEqualByComparingTo("10");verify(fees,never()).createPendingStorageRent(anyString(),any(),any()); + } + + /** 原免费仓租补收失败时保持未审核,补冻结成功后才可审核实扣。 */ + @Test void zeroToPositiveFreezeFailureRecoversBeforeApproval(){ + plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),BigDecimal.ZERO);service.prepare("9",request);service.consume("9",request); + reviews.adjust(701L,"operator",new AdjustRentRequest(1,BigDecimal.ONE,"核对补收","adjust-1")); + service.handle(completed("FREEZE",false,null,null,null)); + assertThat(current.getStatus()).isEqualTo("REVIEW_FREEZE_FAILED");assertThatThrownBy(this::approve).hasMessageContaining("尚未锁定"); + current.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));service.recover(current.getId()); + service.handle(completed("FREEZE",true,"FROZEN","8001",null));approve(); + service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002")); + assertThat(current.getStatus()).isEqualTo("SETTLED");assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + verify(wallet,times(2)).publish(eq("FREEZE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any()); + } + + /** 明细显示实际入库和出库时刻,未扣款不伪造实际扣款金额。 */ + @Test void feeDetailsExposeActualTimesAndConfirmedDebitOnly(){ + ready();service.consume("9",request);when(repository.forFees(List.of(701L))).thenReturn(List.of(current));reviews.describe(List.of(rentFee)); + assertThat(rentFee.getStorageRent().outboundAt()).isEqualTo(current.getConsumedAt().atOffset(ZoneOffset.UTC)); + assertThat(rentFee.getStorageRent().lines().get(0).inboundAt()).isEqualTo(plan.allocations().get(0).inboundAt().atOffset(ZoneOffset.UTC));assertThat(rentFee.getStorageRent().chargedAmount()).isNull(); + approve();service.handle(completed("SETTLE_ACTUAL",true,"SETTLED","8001","8002"));reviews.describe(List.of(rentFee));assertThat(rentFee.getStorageRent().chargedAmount()).isEqualByComparingTo(current.getAmount()); + } + /** 真实费用服务出库时写入待审核来源,零金额也入明细且不发账单。 */ + @ParameterizedTest @ValueSource(strings={"0.00","1.92"}) + void realSourceCreationPersistsPendingFeeWithoutSettlement(String amount){ + var sourceMapper=mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper.class); + var commands=mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper.class); + var outbox=mock(com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService.class); + var real=new OverseasFeeSourceService(sourceMapper,commands,mock(com.baiyue.lastleg.financefee.service.OverseasFeeBusinessGuard.class),outbox,converter,json,mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class)); + ReflectionTestUtils.setField(reviews,"sources",real);plan=new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),new BigDecimal(amount)); + service.prepare("9",request);if(new BigDecimal(amount).signum()>0)service.handle(completed("FREEZE",true,"FROZEN","8001",null)); + service.consume("9",request);var saved=org.mockito.ArgumentCaptor.forClass(OverseasFeeSource.class);verify(sourceMapper,times(1)).insert(saved.capture()); + assertThat(saved.getValue().getReviewStatus()).isEqualTo("PENDING");assertThat(saved.getValue().getWalletSettlement()).isNull(); + assertThat(saved.getValue().getOriginalAmount()).isEqualByComparingTo(amount);assertThat(current.getSourceFeeId()).isEqualTo(saved.getValue().getSourceFeeId()); + assertThat(funding.getWalletBusinessNo()).isEqualTo(current.getId().toString());verifyNoInteractions(outbox); + verify(wallet,never()).publish(eq("SETTLE_ACTUAL"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + /** 升级前已发出的结算仍按原回执收尾,不补审或再发一次扣款。 */ + @Test void legacyConsumedSettlementStillCompletesOnce(){ + ready();current.setConsumedAt(plan.calculatedAt());current.setStatus("CONSUMED");current.setSettleEventId("old-settle"); + var event=new WalletCommandCompletedV1("old-settle","SETTLE",101L,"STORAGE_RENT",current.getId().toString(),"SUCCEEDED","SETTLED",current.getAmount(),null,null,Instant.now(),"8001","8002"); + service.handle(event);service.handle(event);assertThat(current.getStatus()).isEqualTo("SETTLED"); + verify(fees,times(1)).publishSettled(anyString(),anyString(),any(),any(),any());verify(fees,never()).createPendingStorageRent(anyString(),any(),any()); + verify(wallet,times(1)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + /** 审批后才申请原冻结的实际扣费。 */ + private void approve(){reviews.review(701L,"operator","审核员",new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest("APPROVE",null,"review-1",rentFee.getSourceVersion()));} + /** 开始一次已确认冻结的真实扫码。 */ + private void ready(){service.prepare("9",request);service.handle(completed("FREEZE",true,"FROZEN","8001",null));} + /** Finance 按指令原金额回执,失败时 hold 和 ledger 均为空。 */ + private WalletCommandCompletedV1 completed(String operation,boolean success,String status,String hold,String ledger){ + String id=switch(operation){case "FREEZE"->current.getFreezeEventId();case "SETTLE_ACTUAL"->current.getSettleEventId();default->current.getReleaseEventId();}; + return new WalletCommandCompletedV1(id,operation,101L,"STORAGE_RENT",current.getId().toString(),success?"SUCCEEDED":"FAILED",status,current.getAmount(),success?null:"FIN-WALLET-409",success?null:"稍后重试",Instant.now(),hold,ledger); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java new file mode 100644 index 000000000..dd0dd5d66 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java @@ -0,0 +1,123 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 实际库存、业务单和租金原托之间的归属校验,防止错客户与错工单扣款。 */ +class StorageRentSourceResolverTest { + private final StorageRentRepository repository=mock(StorageRentRepository.class); + private final StorageRentSourceResolver service=new StorageRentSourceResolver(); + private final com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway pricing=mock(com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway.class); + private final LocalDateTime at=LocalDateTime.of(2026,10,22,0,0); + private final PrepareRequest request=new PrepareRequest("DISPATCH","DSP-1",11L,21L,4L,null,null,"scan-1",null,null,null); + private DispatchAggregate dispatch; + private DispatchItem item; + private InventoryPosition position; + private LastlegOrder order; + + /** 提供同仓同客户真实排车、库存及自有海外仓入库来源。 */ + @BeforeEach void setup(){ + ReflectionTestUtils.setField(service,"repository",repository); + ReflectionTestUtils.setField(service,"pricingGateway",pricing); + when(pricing.current("101")).thenReturn(StorageRentCalculator.defaults(101L)); + dispatch=new DispatchAggregate();dispatch.setId(1L);dispatch.setOverseasWarehouseId("9");dispatch.setStatus("ACTIVE");dispatch.setStage("LOADING"); + item=new DispatchItem();item.setId(11L);item.setDispatchOrderId(1L);item.setOverseasWarehouseId("9");item.setCustomerId("101");item.setOrderId(91L);item.setWarehousePalletId(31L); + position=new InventoryPosition();position.setId(21L);position.setCustomerId("101");position.setOverseasWarehouseId("9");position.setPalletId(31L);position.setBalanceId(71L);position.setBatchNo("B-1"); + var balance=new InventoryBalance();balance.setCustomerId("101");balance.setInboundOrderId(91L); + order=new LastlegOrder();order.setId(91L);order.setCustomerId("101");order.setOrderNo("FIRST-1");order.setOrderType("FIRST_LEG");order.setDestinationType("OVERSEAS"); + var share=new StorageRentShare();share.setId(61L);share.setLotId(51L);share.setQuantity(BigDecimal.TEN); + var lot=lot(51L,"TRANSIT",10,BigDecimal.TEN); + when(repository.dispatch("DSP-1")).thenReturn(dispatch);when(repository.dispatchItem(11L)).thenReturn(item); + when(repository.position(21L)).thenReturn(position);when(repository.balance(71L)).thenReturn(balance);when(repository.order(91L)).thenReturn(order); + when(repository.stockShares("101","9",71L,"B-1")).thenReturn(List.of(share));when(repository.lockLot(51L)).thenReturn(lot); + } + + /** 原头程货单独申请换标,租金应收归当前换标单,库龄仍来自原实收。 */ + @Test void independentRelabelOwnsChargeWithoutResettingOriginalAge(){ + item.setSourceBusinessType("RELABEL_ORDER");item.setSourceBusinessNo("RELABEL-1"); + var source=new StorageRentBusinessSource();source.setId(81L);source.setBusinessNo("RELABEL-1");source.setLastlegOrderId(91L);when(repository.relabelNo("101","RELABEL-1")).thenReturn(source); + var plan=service.resolve("9",request,at); + assertThat(plan.owner().businessType()).isEqualTo("RELABEL_ORDER");assertThat(plan.owner().businessId()).isEqualTo(81L); + assertThat(plan.allocations()).hasSize(1);assertThat(plan.allocations().get(0).days()).isEqualTo(10); + assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4");assertThat(plan.amount()).isEqualByComparingTo("0.96"); + } + + /** 当前客户覆盖默认价;同一预占消费保存价,余货下次预占读新价且库龄不变。 */ + @Test void currentCustomerPricingAndSavedSnapshotKeepOriginalAge(){ + var first=customPricing("USD",1,"2","5");when(pricing.current("101")).thenReturn(first); + var prepared=service.resolve("9",request,at);assertThat(prepared.amount()).isEqualByComparingTo("8.00"); + var second=customPricing("CNY",2,"9","20");when(pricing.current("101")).thenReturn(second);clearInvocations(pricing); + var consumed=service.resolve("9",request,at,prepared.pricing());assertThat(consumed.amount()).isEqualByComparingTo("8.00");verifyNoInteractions(pricing); + var remainder=new PrepareRequest("DISPATCH","DSP-1",11L,21L,6L,null,null,"remaining",null,null,null); + var later=service.resolve("9",remainder,at.plusDays(1));assertThat(later.amount()).isEqualByComparingTo("59.40"); + assertThat(later.pricing().currency()).isEqualTo("CNY");assertThat(later.allocations().get(0).days()).isEqualTo(11); + assertThat(later.allocations().get(0).inboundAt()).isEqualTo(prepared.allocations().get(0).inboundAt()); + } + + /** 一个客户配置里的不同业务费率只能作用于自己的计费基数。 */ + @Test void transitAndReturnTiersStaySeparate(){ + var custom=customPricing("USD",1,"2","5"); + assertThat(StorageRentCalculator.unitAmount(custom,"TRANSIT",10)).isEqualByComparingTo("20"); + assertThat(StorageRentCalculator.unitAmount(custom,"RETURN",10)).isEqualByComparingTo("50"); + } + + /** 两组各自从首日到无限尾,便于验证客户覆盖和原币切换。 */ + private Pricing customPricing(String currency,int version,String transit,String returns){return new Pricing("31",101L,version,currency,List.of(new PricingTier("1",1,null,new BigDecimal(transit),1,"STORAGE","CBM"),new PricingTier("2",1,null,new BigDecimal(returns),1,"RETURN_STORAGE","PALLET")),"CUSTOMER");} + + /** 有计租义务却缺原实收份额时必须阻断,不能按零仓租放行。 */ + @Test void eligibleInventoryWithoutLineageBlocksOutbound(){ + when(repository.stockShares("101","9",71L,"B-1")).thenReturn(List.of()); + assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("计租数量不完整"); + order.setDestinationType("FBA");assertThat(service.resolve("9",request,at).allocations()).isEmpty(); + } + + /** 排车绑定仓、库存客户和原托必须同时一致,伪造任一来源都不能扣款。 */ + @Test void mismatchedWarehouseCustomerOrPalletIsRejected(){ + assertThatThrownBy(()->service.resolve("10",request,at)).hasMessageContaining("当前仓"); + position.setCustomerId("102");assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("原客户和原托盘"); + position.setCustomerId("101");position.setPalletId(32L);assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("原客户和原托盘"); + when(repository.position(21L)).thenReturn(null);assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("原客户和原托盘"); + } + + /** 同客户同托盘也必须与排车单绑定的入库订单一致,避免把另一单的库龄记入当前单据。 */ + @Test void mismatchedInboundDocumentIsRejectedEvenWhenCustomerWarehouseAndPalletMatch(){ + item.setOrderId(92L);item.setInboundCode("IN-92"); + var balance=new InventoryBalance();balance.setCustomerId("101");balance.setInboundOrderId(91L);balance.setInboundCode("IN-91"); + when(repository.balance(71L)).thenReturn(balance); + assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("业务单据关联的入库订单"); + } + + /** 退货只计原托商品比例,四件从十件原托出库不再向上取一个整托。 */ + @Test void returnSkuOutboundUsesOriginalPalletFractionAndCurrentReturnOrder(){ + var task=new OutboundPdaTask();task.setOutboundOrderId(41L);task.setCustomerId("101");task.setStatus("PENDING"); + var outbound=new OutboundOrder();outbound.setOverseasWarehouseId("9");outbound.setOutboundType("RETURN_OUTBOUND"); + var line=new OutboundPalletItem();line.setOutboundOrderId(41L);line.setCustomerId("101");line.setCartonMarks("BOX-1");line.setSkuCode("SKU");line.setReturnSkuQuantity(4L);line.setInboundCode("RET-1"); + var ret=new ReturnOrder();ret.setId(81L);ret.setReturnNo("RET-1");ret.setLastlegOrderId(91L); + var carton=new ReturnInventoryCarton();carton.setCustomerId("101");carton.setOverseasWarehouseId("9"); + var share=new StorageRentShare();share.setId(62L);share.setLotId(52L);share.setQuantity(BigDecimal.TEN); + when(repository.task("9","TASK-1")).thenReturn(task);when(repository.outboundItem(12L)).thenReturn(line);when(repository.outbound(41L)).thenReturn(outbound); + when(repository.returnOrder("101","RET-1")).thenReturn(ret);when(repository.carton("BOX-1")).thenReturn(carton);when(repository.shares("101","9","BOX-1","SKU")).thenReturn(List.of(share));when(repository.lockLot(52L)).thenReturn(lot(52L,"RETURN",2,BigDecimal.TEN)); + var r=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"SKU",4L); + var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("1.20"); + assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4"); + var wrong=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"OTHER",4L); + assertThatThrownBy(()->service.resolve("9",wrong,at)).hasMessageContaining("原出库单不一致"); + } + + /** 制造一个明确原托或原体积计费批次。 */ + private StorageRentLot lot(long id,String rule,int days,BigDecimal quantity){var lot=new StorageRentLot();lot.setId(id);lot.setRuleCode(rule);lot.setInboundAt(at.minusDays(days));lot.setOriginalUnits(BigDecimal.ONE);lot.setOriginalQuantity(quantity);lot.setRemainingQuantity(quantity);return lot;} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationServiceTest.java new file mode 100644 index 000000000..ffb22155c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/preparation/StorageRentPreparationServiceTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.storagerent.service.preparation; + +import com.baiyue.lastleg.storagerent.service.StorageRentService; +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 整托预占只在同币种时合计原币,人民币资金总额始终可用于 PDA 确认。 */ +class StorageRentPreparationServiceTest { + private final StorageRentService rents=mock(StorageRentService.class); + private final DeliveryTransferService delivery=mock(DeliveryTransferService.class); + private final StorageRentPreparationService service=new StorageRentPreparationService(); + private final PrepareRequest request=new PrepareRequest("OUTBOUND_PALLET","TASK",null,null,null,null,"PALLET","scan",null,null,null); + private final PdaWarehouseIdentity actor=new PdaWarehouseIdentity("operator","操作员",9L); + /** 沿真实整托规划展开两个扫描,不在测试里假设前端可决定客户金额。 */ + @BeforeEach void setup(){ + ReflectionTestUtils.setField(service,"rents",rents);ReflectionTestUtils.setField(service,"delivery",delivery); + when(delivery.storageRentPalletRequests(eq("9"),eq("operator"),eq("TASK"),eq("PALLET"),any())).thenReturn(List.of(new DeliveryRequests.PdaPick(1L,11L,1L,"one"),new DeliveryRequests.PdaPick(2L,12L,1L,"two"))); + } + /** 两个扫码期间发生币种切换仍只展示可加总的 CNY 金额。 */ + @Test void mixedOriginalCurrenciesDoNotProduceFalseAggregate(){ + when(rents.prepare(eq("9"),any())).thenReturn(result("USD","2","14","7"),result("CNY","3","3","1")); + var view=service.prepare(actor,request);assertThat(view.amount()).isEqualByComparingTo("17");assertThat(view.currency()).isEqualTo("CNY"); + assertThat(view.originalAmount()).isNull();assertThat(view.originalCurrency()).isNull();assertThat(view.exchangeRate()).isNull();assertThat(view.ready()).isTrue(); + } + /** 同币同汇率才保留原币合计与共同汇率。 */ + @Test void sameOriginalCurrencyAggregatesWithItsRealCurrency(){ + when(rents.prepare(eq("9"),any())).thenReturn(result("CNY","2","2","1"),result("CNY","3","3","1")); + var view=service.prepare(actor,request);assertThat(view.amount()).isEqualByComparingTo("5");assertThat(view.originalAmount()).isEqualByComparingTo("5");assertThat(view.originalCurrency()).isEqualTo("CNY");assertThat(view.exchangeRate()).isEqualByComparingTo("1"); + } + /** 构造资金结果时明确原币与人民币,避免测试自身跨币相加。 */ + private PreparationResult result(String currency,String original,String cny,String rate){return new PreparationResult("1","READY",true,new BigDecimal(cny),"CNY",new BigDecimal(original),currency,new BigDecimal(rate),"2026-10",null);} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java index 7546fde91..ea2fdf52c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java @@ -31,6 +31,7 @@ class TransferReturnSourceTest { ReflectionTestUtils.setField(repository, "items", items); ReflectionTestUtils.setField(repository, "relabelItems", relabelItems); var service = new WarehouseTransferService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service, "returnSources", repository); ReflectionTestUtils.setField(service, "inventorySources", mock(com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository.class)); var order = new ReturnOrder(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index 01dc83420..9ef4c4493 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -25,6 +25,7 @@ class WarehouseTransferCartonCountTest { WarehouseTransferCarton a, b; @BeforeEach void setup() { service = spy(new WarehouseTransferService()); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service, "transfers", orders); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java index b7a262373..d6dc02add 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java @@ -64,6 +64,7 @@ class WarehouseTransferRelabelPersistenceTest { ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(WarehouseResourceGateway.class)); ReflectionTestUtils.setField(inventory,"objectMapper",new ObjectMapper().findAndRegisterModules()); completion=new WarehouseTransferRelabelCompletionService(); + org.springframework.test.util.ReflectionTestUtils.setField(completion, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(completion,"allocations",session.getMapper(WarehouseTransferInventoryAllocationMapper.class)); ReflectionTestUtils.setField(completion,"bindings",session.getMapper(WarehouseTransferRelabelBindingMapper.class)); ReflectionTestUtils.setField(completion,"inventoryRows",session.getMapper(InventoryCommandMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 33f365293..699638cce 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -229,6 +229,7 @@ class WarehouseTransferServiceTest { void setUp() { compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new WarehouseTransferService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); ReflectionTestUtils.setField(service, "transportTaskService", transportTasks); var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository(); ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index e714f4955..7b33f8a26 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -19,7 +19,7 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文 test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { // 国内补齐订单/仓库流水表;海外含自有业务四表与当前运输、流水基线。 - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 229], [overseas, overseasSchemas, domesticSchemas, 234]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 229], [overseas, overseasSchemas, domesticSchemas, 238]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -121,6 +121,53 @@ test('同实托六表定义在模块和统一空库入口保持一致', () => { } }) +test('海外费用资金与仓租四表在模块和统一空库基线保持一致', () => { + const files = [ + 'overseas-manual-fee-funding.sql', + 'storage-rent.sql', + ] + const normalize = sql => ('USE baiyue_lastleg;\n' + sql).replace(/CREATE TABLE IF NOT EXISTS /g, 'CREATE TABLE ') + const body = sql => sql.slice(sql.indexOf('(')).replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').trim() + const module = normalize(readFileSync(new URL('../../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8')) + for (const source of files) { + const original = normalize(readFileSync(new URL(`../../lastleg-service/src/main/resources/db/${source}`, import.meta.url), 'utf8')) + for (const [key, ddl] of tableDefinitions(original)) { + const name = key.split('.')[1] + for (const target of [module, overseas]) assert.equal(body(table(target, 'baiyue_lastleg', name)), body(ddl), name) + } + } +}) + +test('退货SKU换标保留原仓租份额的迁移快照', () => { + const column = /storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照'/ + for (const path of ['lastleg-service/src/main/resources/db/lastleg.sql', 'lastleg-service/src/main/resources/db/return-sku-conversion.sql', 'deploy/sql/海外区域统一增量-MySQL8.sql']) { + const ddl = readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8') + assert.match(ddl, column, path) + } + assert.match(table(overseas, 'baiyue_lastleg', 'll_return_sku_conversion'), column) +}) + +test('拆柜成本分摊不强制产生自动客户应收', () => { + assert.match(table(overseas, 'baiyue_lastleg', 'll_destuff_pallet_cost_allocation'), /fee_source_id bigint (?:DEFAULT )?NULL COMMENT '人工审核后关联的海外费用来源ID'/i) +}) + +test('海外费用审核人和采购退款幂等约束完整保留在统一空库基线', () => { + const fee = table(overseas, 'baiyue_lastleg', 'll_finance_fee_source') + assert.match(fee, /reviewer_name varchar\(255\)(?: COLLATE \w+)? (?:DEFAULT )?NULL COMMENT '审核人名称快照'/i) + const procurement = table(overseas, 'baiyue_lastleg', 'll_overseas_procurement_fee') + assert.match(procurement, /idempotency_key VARCHAR\(128\) NULL COMMENT '退款请求幂等键,独立于业务单号'/i) + assert.match(procurement, /UNIQUE KEY uk_ll_procurement_refund_request\s*\(order_id,\s*purpose,\s*idempotency_key\)/i) +}) + +test('海外费用菜单只授予平台管理员,编辑与审核不由菜单隐式授予', () => { + const menu = seedRows(cn, 'iam_permission').find(row => row.permission_code === 'platform:overseas-fee:list') + assert.equal(menu.permission_type, 'MENU') + assert.equal(menu.route_path, '/overseas-business/fees') + const roles = seedRows(cn, 'iam_role') + const grants = seedRows(cn, 'iam_role_permission').filter(row => row.permission_id === menu.id) + assert.deepEqual(grants.map(row => roles.find(role => role.id === row.role_id).role_code), ['PLATFORM_ADMIN']) +}) + test('车辆报价与实测记录保留业务去重和不可变版本', () => { const booking = table(overseas, 'baiyue_lastleg', 'll_fleet_external_booking') assert.match(booking, /quote_source_json JSON NULL/i) @@ -175,7 +222,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 440, iam_role_permission: 624, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 441, iam_role_permission: 625, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalOverseasStorageFeeController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalOverseasStorageFeeController.java index 427dc3cb1..59c211288 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalOverseasStorageFeeController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalOverseasStorageFeeController.java @@ -19,6 +19,14 @@ public class InternalOverseasStorageFeeController { @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expected; @PostConstruct void init() { expected = internalToken.getBytes(StandardCharsets.UTF_8); } + + /** 内部计租只读入口,先校验服务身份再查询客户配置。 */ + @GetMapping("/{customerId}/pricing") + public ApiResponse pricing( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable long customerId) { + verify(token); return ApiResponse.success(service.pricing(customerId)); + } @PostMapping("/defaults") public ApiResponse defaults( @RequestHeader(value="X-Internal-Service-Token", required=false) String token, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java index a9243e68e..ffa415b09 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java @@ -97,4 +97,9 @@ public final class OverseasStorageFeeDtos { Long operatorId, String customerName) {} public record DefaultInitializationResponse(String configId, boolean createdByThisInitialization) {} public record CompensationRequest(@NotNull @Positive Long tenantId, @NotBlank String initializationKey) {} + + /** 计租专用的客户配置一致性快照;默认档位不生成配置记录。 */ + @Schema(name = "OverseasStorageFeePricingView", description = "客户海外仓计租配置快照") + public record PricingView(String configId, Long customerId, Integer version, String currency, + List tiers, String source) {} } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java index 78553f01d..00c8d1716 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java @@ -38,6 +38,12 @@ public class OverseasStorageFeeRepository { public OverseasStorageFeeRuleEntity find(long id) { return ruleMapper.selectById(id); } + + /** 按客户读取唯一有效配置,不借用其他客户或默认初始化标识。 */ + public OverseasStorageFeeRuleEntity findByCustomer(long customerId) { + return ruleMapper.selectOne(new LambdaQueryWrapper() + .eq(OverseasStorageFeeRuleEntity::getTenantId, customerId)); + } public OverseasStorageFeeRuleEntity findByTenantAndKey(long tenantId, String key) { return ruleMapper.selectOne(new LambdaQueryWrapper() .eq(OverseasStorageFeeRuleEntity::getTenantId, tenantId) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java index 00e03b549..363d63f90 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java @@ -12,6 +12,7 @@ import jakarta.annotation.Resource; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import java.util.ArrayList; import java.util.Comparator; @@ -26,6 +27,53 @@ public class OverseasStorageFeeService { @Resource private OverseasStorageFeeRepository repository; + /** 同一可重复读快照中读取主档和阶梯;异常配置拒绝计租,不写入或修补存量。 */ + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public OverseasStorageFeeDtos.PricingView pricing(long customerId) { + if (customerId <= 0) throw new BizException(ResultCode.VALIDATION_FAILED, "客户ID必须为正数"); + var rule = repository.findByCustomer(customerId); + if (rule == null) return new OverseasStorageFeeDtos.PricingView(null, customerId, null, "USD", + pricingViews(tierEntities(0, customerId, defaultTiers())), "DEFAULT"); + if (!Long.valueOf(customerId).equals(rule.getTenantId()) || rule.getId() == null + || rule.getVersion() == null || rule.getVersion() < 0 || Boolean.TRUE.equals(rule.getDeleted()) + || rule.getCurrency() == null || !rule.getCurrency().matches("[A-Z]{3}")) + throw new BizException(ResultCode.CONFLICT, "客户仓租配置无效,请先核对配置"); + var effective = new ArrayList<>(repository.findTiers(List.of(rule.getId()))); + if (effective.stream().anyMatch(tier -> tier == null || !rule.getId().equals(tier.getRuleId()) + || !Long.valueOf(customerId).equals(tier.getTenantId()))) + throw new BizException(ResultCode.CONFLICT, "客户仓租阶梯归属不一致"); + if (effective.stream().anyMatch(tier -> tier.getStartDay() == null || tier.getStartDay() < 1 + || tier.getStartDay() > 36500 || tier.getEndDay() != null && (tier.getEndDay() < 1 || tier.getEndDay() > 36500))) + throw new BizException(ResultCode.CONFLICT, "客户仓租阶梯天数无效"); + for (String type : List.of("STORAGE", "RETURN_STORAGE")) { + if (effective.stream().noneMatch(tier -> type.equals(tier.getFeeType()))) { + if (!"USD".equals(rule.getCurrency())) + throw new BizException(ResultCode.CONFLICT, "非美元仓租配置缺少计租类型,请先补全配置"); + effective.addAll(tierEntities(rule.getId(), customerId, + defaultTiers().stream().filter(tier -> type.equals(tier.feeType())).toList())); + } + } + var normalized = normalize(effective.stream().map(tier -> new OverseasStorageFeeDtos.TierRequest( + tier.getFeeType(), tier.getBillingUnit(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice())).toList()); + for (String type : List.of("STORAGE", "RETURN_STORAGE")) { + var group = normalized.stream().filter(tier -> type.equals(tier.feeType())).toList(); + if (group.get(group.size() - 1).endDay() != null) + throw new BizException(ResultCode.CONFLICT, "客户仓租阶梯必须包含无上限尾档"); + } + effective.sort(Comparator.comparingInt((OverseasStorageFeeTierEntity tier) -> + "STORAGE".equals(tier.getFeeType()) ? 0 : 1).thenComparingInt(OverseasStorageFeeTierEntity::getStartDay)); + return new OverseasStorageFeeDtos.PricingView(String.valueOf(rule.getId()), customerId, rule.getVersion(), + rule.getCurrency(), pricingViews(effective), "CUSTOMER"); + } + + /** 转换为稳定顺序的只读计租档位,补齐默认组的档位ID保持为空。 */ + private static List pricingViews(List tiers) { + var result = new ArrayList(); + for (var tier : tiers) result.add(new OverseasStorageFeeDtos.TierView(tier.getId(), tier.getStartDay(), + tier.getEndDay(), tier.getUnitPrice(), result.size() + 1, tier.getFeeType(), tier.getBillingUnit())); + return List.copyOf(result); + } + public PageResult page(OverseasStorageFeeDtos.PageQuery query) { PageResult source = repository.page(query); Map> tiers = repository.findTiers( diff --git a/warehouse/src/main/resources/db/warehouse-supplier.sql b/warehouse/src/main/resources/db/warehouse-supplier.sql index a31733796..cd0e8f95e 100644 --- a/warehouse/src/main/resources/db/warehouse-supplier.sql +++ b/warehouse/src/main/resources/db/warehouse-supplier.sql @@ -59,6 +59,7 @@ BEGIN END$$ DELIMITER ; CALL supplier_add_column_if_missing('license_file_id', "VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no"); +CALL supplier_add_column_if_missing('trucking_scope', "VARCHAR(16) NULL COMMENT '拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空' AFTER category"); CALL supplier_add_column_if_missing('created_by', "BIGINT UNSIGNED NULL COMMENT '创建人ID'"); CALL supplier_add_column_if_missing('created_at', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'"); CALL supplier_add_column_if_missing('updated_by', "BIGINT UNSIGNED NULL COMMENT '最后修改人ID'"); diff --git a/warehouse/src/main/resources/openapi/warehouse-v1.yaml b/warehouse/src/main/resources/openapi/warehouse-v1.yaml index a8b963737..1c4fead74 100644 --- a/warehouse/src/main/resources/openapi/warehouse-v1.yaml +++ b/warehouse/src/main/resources/openapi/warehouse-v1.yaml @@ -3,6 +3,31 @@ info: title: Warehouse catalog and receiving options version: 1.0.0 paths: + /internal/warehouse/v1/customer-overseas-storage-fees/{customerId}/pricing: + get: + summary: 读取客户海外仓自动计租配置 + description: 内部只读接口,不经公网网关。按客户读取同一REPEATABLE_READ快照中的配置及阶梯,不创建或修改配置。无配置返回完整USD默认中转及退货两表;USD配置漏组仅补齐缺失组。非USD配置漏组、已配置组不连续或缺少无上限尾档时明确失败,不能因读取异常降级为默认价。完整客户配置保留原币种,由调用方按确认汇率换算。 + security: + - internalServiceToken: [] + parameters: + - name: customerId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + responses: + '200': + description: 统一业务响应,code为200时返回完整计租快照;客户ID或配置非法返回业务失败code,不返回默认价。 + content: + application/json: + schema: + type: object + required: [code] + properties: + code: {type: string} + message: {type: string} + data: + $ref: '#/components/schemas/OverseasStorageFeePricingView' + '403': {description: 内部服务凭据缺失或错误} /internal/warehouses/platform-metadata/batch: post: summary: 内部批量读取公共仓库历史展示资料 @@ -94,6 +119,31 @@ paths: '200': {description: 返回仓库 ID、编码、名称、类型和国家编码} components: schemas: + OverseasStorageFeePricingView: + type: object + required: [customerId, currency, tiers, source] + properties: + configId: {type: string, nullable: true, description: 客户配置ID,无配置时为空} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + version: {type: integer, nullable: true, minimum: 0, description: 客户配置版本,无配置时为空} + currency: {type: string, pattern: '^[A-Z]{3}$'} + source: {type: string, enum: [CUSTOMER, DEFAULT], description: CUSTOMER包含仅补全缺失默认组的USD客户配置} + tiers: + type: array + minItems: 2 + items: + $ref: '#/components/schemas/OverseasStorageFeePricingTier' + OverseasStorageFeePricingTier: + type: object + required: [startDay, unitPrice, sortNo, feeType, billingUnit] + properties: + id: {type: string, nullable: true, description: 客户阶梯ID,默认或补齐档位为空} + startDay: {type: integer, minimum: 1} + endDay: {type: integer, nullable: true, minimum: 1, description: 含当日,空值为无上限尾档} + unitPrice: {type: number, minimum: 0, description: 每CBM每天或每托每天的单价,币种取快照currency} + sortNo: {type: integer, minimum: 1} + feeType: {type: string, enum: [STORAGE, RETURN_STORAGE], description: STORAGE中转仓租,RETURN_STORAGE退货仓租} + billingUnit: {type: string, enum: [CBM, PALLET]} ReceivingWarehouseOption: type: object required: [id, customerId, warehouseCode, warehouseName, warehouseType, status] diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalOverseasStoragePricingControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalOverseasStoragePricingControllerTest.java new file mode 100644 index 000000000..025722f0e --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalOverseasStoragePricingControllerTest.java @@ -0,0 +1,28 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.OverseasStorageFeeDtos; +import com.baiyue.warehouse.service.OverseasStorageFeeService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.GetMapping; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InternalOverseasStoragePricingControllerTest { + @Test void pricingRequiresInternalTokenBeforeReadingCustomerAndReturnsSnapshot() throws Exception { + var service=mock(OverseasStorageFeeService.class); + var controller=new InternalOverseasStorageFeeController(); + ReflectionTestUtils.setField(controller,"service",service); + ReflectionTestUtils.setField(controller,"internalToken","internal-test"); controller.init(); + assertThatThrownBy(() -> controller.pricing(null,17)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.pricing("wrong",17)).isInstanceOf(BizException.class); + verifyNoInteractions(service); + var expected=new OverseasStorageFeeDtos.PricingView(null,17L,null,"USD",List.of(),"DEFAULT"); + when(service.pricing(17)).thenReturn(expected); + assertThat(controller.pricing("internal-test",17).data()).isSameAs(expected); + assertThat(InternalOverseasStorageFeeController.class.getMethod("pricing",String.class,long.class) + .getAnnotation(GetMapping.class).value()).containsExactly("/{customerId}/pricing"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStoragePricingTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStoragePricingTest.java new file mode 100644 index 000000000..f7abe2352 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStoragePricingTest.java @@ -0,0 +1,140 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity; +import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity; +import com.baiyue.warehouse.repository.OverseasStorageFeeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OverseasStoragePricingTest { + private final OverseasStorageFeeRepository repository = mock(OverseasStorageFeeRepository.class); + private final OverseasStorageFeeService service = new OverseasStorageFeeService(); + + @BeforeEach void setup() { ReflectionTestUtils.setField(service, "repository", repository); } + + @Test void absentConfigurationReturnsBothConfirmedDefaultTablesWithoutWriting() { + var pricing = service.pricing(17); + assertThat(pricing.source()).isEqualTo("DEFAULT"); + assertThat(pricing.customerId()).isEqualTo(17); + assertThat(pricing.configId()).isNull(); assertThat(pricing.version()).isNull(); + assertThat(pricing.currency()).isEqualTo("USD"); + assertThat(pricing.tiers()).hasSize(9).allSatisfy(tier -> assertThat(tier.id()).isNull()); + assertThat(pricing.tiers().stream().filter(tier -> "STORAGE".equals(tier.feeType())).toList()) + .extracting(tier -> tier.unitPrice().stripTrailingZeros()).containsExactly( + BigDecimal.ZERO, new BigDecimal("0.8"), BigDecimal.ONE, new BigDecimal("1.5"), new BigDecimal("2")); + assertThat(pricing.tiers().stream().filter(tier -> "RETURN_STORAGE".equals(tier.feeType())).toList()) + .extracting(tier -> tier.unitPrice().stripTrailingZeros()).containsExactly( + new BigDecimal("1.5"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4")); + assertThat(pricing.tiers().get(0).startDay()).isEqualTo(1); + assertThat(pricing.tiers().get(0).endDay()).isEqualTo(7); + verify(repository).findByCustomer(17); verifyNoMoreInteractions(repository); + } + + @Test void configuredCustomerKeepsVersionCurrencyAndPricesInSortedOrder() { + configured("CNY", List.of(tier("RETURN_STORAGE",1,null,"13"),tier("STORAGE",8,null,"6"),tier("STORAGE",1,7,"0"))); + var pricing = service.pricing(17); + assertThat(pricing.source()).isEqualTo("CUSTOMER"); + assertThat(pricing.configId()).isEqualTo("51"); assertThat(pricing.version()).isEqualTo(3); + assertThat(pricing.currency()).isEqualTo("CNY"); + assertThat(pricing.tiers()).extracting(tier -> tier.unitPrice().toPlainString()).containsExactly("0","6","13"); + verify(repository).findByCustomer(17); verify(repository).findTiers(List.of(51L)); verifyNoMoreInteractions(repository); + } + + @ParameterizedTest @ValueSource(strings={"STORAGE","RETURN_STORAGE"}) + void dollarConfigurationCompletesOnlyMissingGroup(String existingType) { + configured("USD",List.of(tier(existingType,1,null,"9"))); + var pricing=service.pricing(17); + assertThat(pricing.tiers().stream().filter(tier -> existingType.equals(tier.feeType())).toList()) + .singleElement().satisfies(tier -> assertThat(tier.unitPrice()).isEqualByComparingTo("9")); + assertThat(pricing.tiers().stream().filter(tier -> !existingType.equals(tier.feeType())).toList()) + .hasSize("STORAGE".equals(existingType)?4:5).allSatisfy(tier -> assertThat(tier.id()).isNull()); + } + + @Test void foreignCurrencyMissingGroupCannotMixDollarDefaults() { + configured("CNY",List.of(tier("STORAGE",1,null,"9"))); + assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("非美元"); + } + + @Test void finiteLastBandRefusesBillingInsteadOfZeroOrDefaultPrice() { + configured("USD",List.of(tier("STORAGE",1,30,"9"))); + assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("无上限"); + } + + @Test void gapAndUnknownUnitRefuseBilling() { + configured("USD",List.of(tier("STORAGE",1,7,"0"),tier("STORAGE",9,null,"9"))); + assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("连续"); + var invalid=tier("STORAGE",1,null,"9"); invalid.setBillingUnit("PALLET"); + when(repository.findTiers(List.of(51L))).thenReturn(List.of(invalid)); + assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("不匹配"); + } + + @Test void wrongCustomerTierRefusesBilling() { + var wrong=tier("STORAGE",1,null,"9"); wrong.setTenantId(18L); + configured("USD",List.of(wrong)); + assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(BizException.class).hasMessageContaining("归属"); + } + + @Test void configurationReadFailurePropagatesWithoutDefaultFallback() { + when(repository.findByCustomer(17)).thenThrow(new IllegalStateException("database unavailable")); + assertThatThrownBy(() -> service.pricing(17)).isInstanceOf(IllegalStateException.class); + } + + @ParameterizedTest @ValueSource(longs={0,-1}) + void invalidCustomerNeverQueries(long customer) { + assertThatThrownBy(() -> service.pricing(customer)).isInstanceOf(BizException.class); + verifyNoInteractions(repository); + } + + @Test void pricingRequiresReadOnlyRepeatableSnapshot() throws Exception { + var transaction=OverseasStorageFeeService.class.getMethod("pricing",long.class).getAnnotation(Transactional.class); + assertThat(transaction.readOnly()).isTrue(); assertThat(transaction.isolation()).isEqualTo(Isolation.REPEATABLE_READ); + } + + @Test void ruleAndTierReadsShareOneActiveRepeatableReadTransactionConnection() { + var data=new org.springframework.jdbc.datasource.DriverManagerDataSource( + "jdbc:h2:mem:pricing-snapshot-"+java.util.UUID.randomUUID(),"sa",""); + var transactionManager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(data); + var proxy=new org.springframework.aop.framework.ProxyFactory(service); proxy.setProxyTargetClass(true); + proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(transactionManager, + new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); + var transactional=(OverseasStorageFeeService)proxy.getProxy(); + var connection=new java.util.concurrent.atomic.AtomicReference(); + when(repository.findByCustomer(17)).thenAnswer(invocation -> { + assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); + assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isCurrentTransactionReadOnly()).isTrue(); + var current=org.springframework.jdbc.datasource.DataSourceUtils.getConnection(data); connection.set(current); + assertThat(current.getTransactionIsolation()).isEqualTo(java.sql.Connection.TRANSACTION_REPEATABLE_READ); + var rule=new OverseasStorageFeeRuleEntity(); rule.setId(51L); rule.setTenantId(17L); + rule.setCurrency("USD"); rule.setVersion(3); return rule; + }); + when(repository.findTiers(List.of(51L))).thenAnswer(invocation -> { + assertThat(org.springframework.jdbc.datasource.DataSourceUtils.getConnection(data)).isSameAs(connection.get()); + assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); + return List.of(tier("STORAGE",1,null,"9"),tier("RETURN_STORAGE",1,null,"8")); + }); + assertThat(transactional.pricing(17).version()).isEqualTo(3); + assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + } + + private void configured(String currency,List tiers) { + var rule=new OverseasStorageFeeRuleEntity(); rule.setId(51L); rule.setTenantId(17L); + rule.setCurrency(currency); rule.setVersion(3); + when(repository.findByCustomer(17)).thenReturn(rule); when(repository.findTiers(List.of(51L))).thenReturn(tiers); + } + + private OverseasStorageFeeTierEntity tier(String type,int start,Integer end,String price) { + var tier=new OverseasStorageFeeTierEntity(); tier.setId(71L); tier.setRuleId(51L); tier.setTenantId(17L); + tier.setFeeType(type); tier.setBillingUnit("STORAGE".equals(type)?"CBM":"PALLET"); + tier.setStartDay(start); tier.setEndDay(end); tier.setUnitPrice(new BigDecimal(price)); return tier; + } +} -- 2.54.0 From 294bfb60203e3ff8c240dbaba9fe31292c343d3f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 26 Oct 2026 13:42:10 +0800 Subject: [PATCH 02/70] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V34__procurement_direction.sql | 9 ++++ ...formFinanceWalletRefundControllerTest.java | 54 +++++++++++++++++++ lastleg-service/docs/procurement-direction.md | 12 +++++ .../mapper/ProcurementDirectionMapper.java | 14 +++++ .../ProcurementDirectionRepository.java | 14 +++++ .../service/ProcurementDirection.java | 22 ++++++++ ...erseasFeeSourceControllerSecurityTest.java | 47 ++++++++++++++++ .../ProcurementDirectionPersistenceTest.java | 31 +++++++++++ 8 files changed, 203 insertions(+) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql create mode 100644 finance/src/test/java/com/baiyue/finance/PlatformFinanceWalletRefundControllerTest.java create mode 100644 lastleg-service/docs/procurement-direction.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementDirection.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeSourceControllerSecurityTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql new file mode 100644 index 000000000..3f38b7bad --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql @@ -0,0 +1,9 @@ +-- 先于新版本 Lastleg 上线执行。历史订单保持海外采购回国内,不改数量、资金或交寄事实。 +SET @procurement_direction_ddl = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'll_overseas_procurement_order' AND COLUMN_NAME = 'procurement_direction') = 0, + 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN procurement_direction VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS_TO_DOMESTIC'' COMMENT ''采购方向:海外采购回国内/国内采购发海外''', + 'SELECT 1'); +PREPARE procurement_direction_stmt FROM @procurement_direction_ddl; +EXECUTE procurement_direction_stmt; +DEALLOCATE PREPARE procurement_direction_stmt; diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceWalletRefundControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceWalletRefundControllerTest.java new file mode 100644 index 000000000..0ef3487b2 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceWalletRefundControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.finance; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.controller.PlatformFinanceWalletController; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest; +import com.baiyue.finance.service.FinanceWalletService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.math.BigDecimal; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 平台退款入口鉴权、必填原因和稳定幂等引用的回归。 */ +class PlatformFinanceWalletRefundControllerTest { + private final FinanceWalletService service = mock(FinanceWalletService.class); + private final PlatformFinanceWalletController controller = new PlatformFinanceWalletController(service); + private final WalletRefundRequest request = new WalletRefundRequest(new BigDecimal("12.00"), "退回多收费用"); + + /** 客户即使携带同名权限也不能借平台入口操作他人资金。 */ + @Test void nonPlatformPrincipalsAreDeniedBeforeWalletAccess() { + for (var user : new AuthenticatedUser[]{null, user("CLIENT", 9L), user("PLATFORM", 9L)}) + assertThatThrownBy(() -> controller.refund(user, 9, 1, "key", request)) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(service); + } + + /** 同一客户请求键生成同一退款引用,实际防重与金额上限仍由资金事务执行。 */ + @Test void authorizedCallsUseStableReferenceAndExistingAdjustPermission() throws Exception { + controller.refund(user("PLATFORM", 0L), 9, 1, " key ", request); + controller.refund(user("PLATFORM", 0L), 9, 1, "key", request); + var reference = org.mockito.ArgumentCaptor.forClass(String.class); + verify(service, times(2)).refundConsumption(eq(9L), eq(1L), reference.capture(), eq("key"), + eq(request.amount()), eq(7L), isNull(), eq(request.reason())); + assertThat(reference.getAllValues()).allSatisfy(value -> assertThat(value).startsWith("MANUAL_REFUND:")); + assertThat(reference.getAllValues().get(0)).isEqualTo(reference.getAllValues().get(1)); + assertThat(PlatformFinanceWalletController.class.getMethod("refund", AuthenticatedUser.class, + long.class, long.class, String.class, WalletRefundRequest.class).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:finance:customer-account:adjust')"); + } + + /** 空说明或非法幂等键在触达资金前失败。 */ + @Test void missingReasonAndIdempotencyAreRejected() { + assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, " ", request)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, "key", + new WalletRefundRequest(BigDecimal.ONE, " "))).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(service); + } + + private AuthenticatedUser user(String app, long tenant) { + return new AuthenticatedUser(7L, tenant, 0L, null, "finance", app, "WEB", + Set.of("platform:finance:customer-account:adjust")); + } +} diff --git a/lastleg-service/docs/procurement-direction.md b/lastleg-service/docs/procurement-direction.md new file mode 100644 index 000000000..9d1dbea0b --- /dev/null +++ b/lastleg-service/docs/procurement-direction.md @@ -0,0 +1,12 @@ +# 采购双方向履约 + +2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。 + +- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;待审核且无购买、收货事实时平台可核定方向,审核通过后不再通过通用编辑修改方向。 +- 起运收货仓按方向读取 Warehouse 权威公共启用仓目录,国内方向只接受 DOMESTIC,进口方向保持海外 RECEIVING 能力约束。实际收货、计划仓更改、实际仓更正及面单寄件地址均校验同一方向。 +- 出口流程:审核 → 国内购买 → 国内分次实收并确认 → 装箱 → 国际面单、预估运费冻结、贴单 → 国内国际交寄 → 海外分次实收并确认 → 海外核定实际费用 → 寄海外客户。正常终态仍为 SHIPPED_TO_CUSTOMER,取消、拒绝、异常关闭规则保留。 +- 此次沿用单商品、一次购买、两段各整批交寄及 Admin 台账,不新增 WMS 库存、PDA 入账、分批购买或分批交寄。目的地接收地址沿用国际面单地址快照,最终客户地址由下单提供,最终面单变更仍需客户凭证。 +- 为保护历史数据关联,技术编码 OVERSEAS/DOMESTIC、overseas_received_at/domestic_received_at 及既有状态编码分别表示起运段/目的地段;业务展示按方向转换国内、海外。权限编码、费用用途 DOMESTIC_FREIGHT 和资金引用保持兼容;费用名称按实际目的地区域保存。 +- 版本与幂等覆盖方向;未传新字段的旧请求序列化忽略空方向,保留旧哈希。上线先执行 V34,再发布 Lastleg、Admin、Client;不能仅发布前端。 + +自动验证覆盖方向持久化与幂等、国内仓主档校验、两段出口地址与交寄终态,以及页面切换方向后的仓库候选和旧请求重试。真实服务与人工截图验收另行记录。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java new file mode 100644 index 000000000..1018cc61c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.procurement.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 方向变更只读门禁,购买与收货事实仍归原履约模型。 */ +@Mapper +public interface ProcurementDirectionMapper { + /** 已发生的购买或起运仓事实禁止通过审核切换方向。 */ + @Select("SELECT EXISTS(SELECT 1 FROM ll_procurement_flow WHERE order_id=#{orderId} AND " + + "(purchased_at IS NOT NULL OR actual_warehouse_id IS NOT NULL OR overseas_shipped_at IS NOT NULL))") + boolean hasStarted(@Param("orderId") long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java new file mode 100644 index 000000000..a375d947d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.procurement.repository; + +import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** 采购方向审核的数据访问边界。 */ +@Repository +@RequiredArgsConstructor +public class ProcurementDirectionRepository { + private final ProcurementDirectionMapper mapper; + /** 在订单行锁内读取履约事实,避免改向重解释原始货物来源。 */ + public boolean hasStarted(long orderId) { return mapper.hasStarted(orderId); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementDirection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementDirection.java new file mode 100644 index 000000000..287b59e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementDirection.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.procurement.service; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** 采购方向决定起运仓类型,历史两段编码分别代表起运段和目的地段。 */ +public final class ProcurementDirection { + public static final String IMPORT = "OVERSEAS_TO_DOMESTIC"; + public static final String EXPORT = "DOMESTIC_TO_OVERSEAS"; + private ProcurementDirection() { } + /** 旧订单缺少方向时保持原进口流程。 */ + public static String normalize(String value) { + if (value == null) return IMPORT; + if (!IMPORT.equals(value) && !EXPORT.equals(value)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购方向无效"); + return value; + } + /** 展示区域随方向切换,不改历史技术段编码和关联。 */ + public static String display(String text, String direction) { + if (text == null || !EXPORT.equals(normalize(direction))) return text; + return text.replace("海外", "__ORIGIN__").replace("国内", "海外").replace("__ORIGIN__", "国内"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeSourceControllerSecurityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeSourceControllerSecurityTest.java new file mode 100644 index 000000000..7225ab159 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/PlatformOverseasFeeSourceControllerSecurityTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.financefee.controller.PlatformOverseasFeeSourceController; +import com.baiyue.lastleg.financefee.service.OverseasFeeDeliveryService; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.financefee.service.OverseasManualFeeService; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; + +import java.util.Set; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.mockito.Mockito.mock; + +class PlatformOverseasFeeSourceControllerSecurityTest { + @Test + void rejectsNonPlatformTenantBeforeReadingFeeData() throws Exception { + var controller = new PlatformOverseasFeeSourceController( + mock(OverseasFeeSourceService.class), mock(OverseasManualFeeService.class), + mock(OverseasFeeDeliveryService.class)); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(identity(new AuthenticatedUser(7L, 42L, 0L, 1L, + "customer-user", "LASTLEG", "WEB", Set.of()))) + .build(); + + mvc.perform(get("/api/platform/v1/last-leg/finance-fee-sources")) + .andExpect(status().isForbidden()); + } + + private static HandlerMethodArgumentResolver identity(AuthenticatedUser user) { + return new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType() == AuthenticatedUser.class; + } + + @Override public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer container, + org.springframework.web.context.request.NativeWebRequest request, + org.springframework.web.bind.support.WebDataBinderFactory binder) { + return user; + } + }; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java new file mode 100644 index 000000000..6ab56a0e0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper; +import com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class ProcurementDirectionPersistenceTest { + @Test void directionGateReadsOnlyThisOrdersRecordedFacts() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_procurement_flow(order_id BIGINT PRIMARY KEY,purchased_at TIMESTAMP,actual_warehouse_id BIGINT,overseas_shipped_at TIMESTAMP)"); + var config = new MybatisConfiguration(); config.addMapper(ProcurementDirectionMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var repository = new ProcurementDirectionRepository(new SqlSessionTemplate(factory.getObject()).getMapper(ProcurementDirectionMapper.class)); + assertThat(repository.hasStarted(1)).isFalse(); + jdbc.update("INSERT INTO ll_procurement_flow(order_id) VALUES(1)"); + assertThat(repository.hasStarted(1)).isFalse(); + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=CURRENT_TIMESTAMP WHERE order_id=1"); + assertThat(repository.hasStarted(1)).isTrue(); + assertThat(repository.hasStarted(2)).isFalse(); + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=NULL,actual_warehouse_id=91 WHERE order_id=1"); + assertThat(repository.hasStarted(1)).isTrue(); + } +} -- 2.54.0 From 98f7d81bb1792b6e9947e02175743cf5db7716d6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 13:58:40 +0800 Subject: [PATCH 03/70] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase4-carrier-nodes-contract.test.mjs | 22 +++++++++++++++++++ .../phase4-carrier-nodes.mjs | 13 +++-------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index e2ddb9e05..e5d1dec26 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -279,6 +279,28 @@ test('task page permits explicit platform scope zero and positive customer scope } }) +test('船节点分页接受已规范化的非 ISO 柜号,仍拒绝空值和未规范化输入', async () => { + for (const trackingNo of ['JZXH10231043', 'TEST-GRAY-CAB-NOISO', 'ZCSU7881971']) { + assert.equal(await taskPageStructure({ trackingNo }), true, trackingNo) + } + for (const trackingNo of ['', ' ', '\t', 'jzxh10231043', ' JZXH10231043', 'JZXH 10231043', null, undefined, 123]) { + assert.equal(await taskPageStructure({ trackingNo }), false, String(trackingNo)) + } +}) + +test('非 ISO 柜号沿本轮归属核验完成船节点详情及人工节点链', async () => { + const fixture = markerOwnedContext() + fixture.task.trackingNo = 'TEST-GRAY-CAB-NOISO' + fixture.context.createdResources.phase3CarrierScheduleQuery.trackingNo = fixture.task.trackingNo + + await runUntilPickupReady(fixture.context) + + assert.equal(fixture.task.latestActualNode, 'PICKUP_READY') + assert.ok(fixture.assertions.every((check) => check.passed)) + assert.ok(fixture.context.calls.filter((call) => call.method === 'POST') + .every((call) => call.path === `${BASE}/${QUERY_ID}/manual-nodes`)) +}) + test('task page still rejects negative, missing, numeric and noncanonical customer scopes', async () => { for (const customerId of ['-1', '-9007199254740993002', '', '00', '01', '+0', '0.0', 'customer', null, undefined, 0, 7]) { assert.equal(await taskPageStructure({ customerId }), false, `customerId=${String(customerId)}`) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index e1e7ad0d9..5943b4783 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -10,7 +10,6 @@ export const STANDARD_NODES = Object.freeze([ ]) export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10]))) -const ISO_CONTAINER_PATTERN = /^[A-Z]{3}[UJZ][0-9]{7}$/ function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } @@ -20,16 +19,10 @@ function validLongId(value) { return typeof value === 'string' && /^[1-9]\d{15,} function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' } function validCustomerScope(value) { return typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value) } function validOperatorId(value) { return value === '0' || validNumericId(value) } +// 柜号沿现行合同仅要求非空且已规范化,不能再以旧 ISO 格式或校验位拒绝合法业务事实。 function validContainerNo(value) { - if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false - const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, - 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38] - let sum = 0 - for (let index = 0; index < 10; index += 1) { - const code = value.charCodeAt(index) - sum += (code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]) * (2 ** index) - } - return Number(value[10]) === (sum % 11) % 10 + return typeof value === 'string' && value.length > 0 + && value === value.replace(/\s/g, '').toUpperCase() } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) } -- 2.54.0 From 17cf05c4e1baa61f03a1dd913fe6e3eb519eb62a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 15:29:51 +0800 Subject: [PATCH 04/70] fix(db): correct overseas initialization table count --- deploy/sql/海外区域统一增量-MySQL8.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index dd8a4fd9b..c422ca478 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -5106,4 +5106,4 @@ CREATE TABLE ll_storage_rent_preparation ( KEY idx_storage_rent_release (release_status,updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 238 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 239 AS initialized_tables; -- 2.54.0 From 3f8a0df43369e3d34600847ab4c6b62ae7c98a68 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 26 Oct 2026 15:35:14 +0800 Subject: [PATCH 05/70] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/docs/procurement-direction.md | 2 +- .../service/OverseasProcurementService.java | 7 +++-- .../openapi/overseas-business-v1.yaml | 2 +- .../OverseasProcurementServiceTest.java | 29 ++++++++++++++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lastleg-service/docs/procurement-direction.md b/lastleg-service/docs/procurement-direction.md index 9d1dbea0b..1066c3d71 100644 --- a/lastleg-service/docs/procurement-direction.md +++ b/lastleg-service/docs/procurement-direction.md @@ -2,7 +2,7 @@ 2026-10-26 按用户要求增加国内采购发往海外,保留海外采购回国内。 -- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;待审核且无购买、收货事实时平台可核定方向,审核通过后不再通过通用编辑修改方向。 +- `procurementDirection` 为 `OVERSEAS_TO_DOMESTIC` 或 `DOMESTIC_TO_OVERSEAS`,不传时保持历史进口方向。客户建单可选;平台审核仅展示客户下单方向,不允许审核改向;采购仓按该方向筛选。审核底部直接取消、拒绝或通过,拒绝必须填写具体原因。 - 起运收货仓按方向读取 Warehouse 权威公共启用仓目录,国内方向只接受 DOMESTIC,进口方向保持海外 RECEIVING 能力约束。实际收货、计划仓更改、实际仓更正及面单寄件地址均校验同一方向。 - 出口流程:审核 → 国内购买 → 国内分次实收并确认 → 装箱 → 国际面单、预估运费冻结、贴单 → 国内国际交寄 → 海外分次实收并确认 → 海外核定实际费用 → 寄海外客户。正常终态仍为 SHIPPED_TO_CUSTOMER,取消、拒绝、异常关闭规则保留。 - 此次沿用单商品、一次购买、两段各整批交寄及 Admin 台账,不新增 WMS 库存、PDA 入账、分批购买或分批交寄。目的地接收地址沿用国际面单地址快照,最终客户地址由下单提供,最终面单变更仍需客户凭证。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 2075a6ebd..8894c6e1c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -234,8 +234,11 @@ public class OverseasProcurementService { if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); String direction = request.procurementDirection() == null ? ProcurementDirection.normalize(order.getProcurementDirection()) : ProcurementDirection.normalize(request.procurementDirection()); - if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection())) && directionRepository.hasStarted(order.getId())) { - throw conflict("本单已有购买或收货事实,不能切换采购方向"); + if (!direction.equals(ProcurementDirection.normalize(order.getProcurementDirection()))) { + throw conflict("审核不能修改采购方向,请按客户下单方向选择采购仓"); + } + if (REJECTED.equals(next) && trim(request.remark()) == null) { + throw badRequest("请填写拒绝原因"); } Map before = reviewSnapshot(order); if (WAIT_PURCHASE.equals(next)) { diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index e23c85b8b..9e3ca0c81 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -5055,7 +5055,7 @@ components: ProcurementReview: type: object required: [version, decision, idempotencyKey] - description: 审核通过必须选择与采购方向匹配的有效公共收货仓;国内采购选择国内仓,海外采购选择海外收货仓;拒绝不要求仓库。已有购买或收货事实不得切换方向。 + description: 审核通过必须选择与采购方向匹配的有效公共收货仓;国内采购选择国内仓,海外采购选择海外收货仓;拒绝不要求仓库,但 remark 必须填写非空白拒绝原因。审核不得修改客户下单采购方向。 properties: procurementDirection: { $ref: '#/components/schemas/ProcurementDirection' } version: { type: integer, format: int64, minimum: 0 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 8d52bdc3d..bb02ba618 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -195,6 +195,7 @@ class OverseasProcurementServiceTest { @Test void exportReviewPersistsDirectionAndCannotReplayWithChangedDirection() { var order = order(OverseasProcurementService.PENDING_REVIEW,0,0); + order.setProcurementDirection(ProcurementDirection.EXPORT); when(orders.lockNo("100","HC1")).thenReturn(order); when(orders.findNo("100","HC1")).thenReturn(order); when(histories.listOrder("100",1L)).thenReturn(List.of()); @@ -221,7 +222,7 @@ class OverseasProcurementServiceTest { @Test void pendingReviewCannotReverseDirectionOfAnAlreadyRecordedPurchase() { var order = order(OverseasProcurementService.PENDING_REVIEW,0,0); when(orders.lockNo("100","HC1")).thenReturn(order); - when(directionRepository.hasStarted(1L)).thenReturn(true); + assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7", new OverseasProcurementRequests.Review(0L,"APPROVE","export-after-purchase","checked",10L,ProcurementDirection.EXPORT))); verify(warehouseDirectory,never()).required(10L,ProcurementDirection.EXPORT); @@ -239,4 +240,30 @@ class OverseasProcurementServiceTest { assertEquals("US, California, test destination",detail.receivingAddress()); assertEquals(OverseasProcurementService.PENDING_REVIEW,detail.summary().status()); } + + @Test void rejectionRequiresSpecificNonblankReasonWithoutAWarehouse() { + var order = order(OverseasProcurementService.PENDING_REVIEW,0,0); + when(orders.lockNo("100","HC1")).thenReturn(order); + for (String reason : new String[]{null,""," "}) { + var error = assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7", + new OverseasProcurementRequests.Review(0L,"REJECT","reject-empty",reason,null))); + assertEquals("请填写拒绝原因",error.getReason()); + } + assertEquals(OverseasProcurementService.PENDING_REVIEW,order.getStatus()); + when(orders.findNo("100","HC1")).thenReturn(order); + when(histories.listOrder("100",1L)).thenReturn(List.of()); + var result=service.review("100","HC1","7",new OverseasProcurementRequests.Review(0L,"REJECT","reject-reason","供应商无法供货",null)); + assertEquals(OverseasProcurementService.REJECTED,result.summary().status()); + assertEquals("供应商无法供货",result.reviewRemark()); + verify(warehouseDirectory,never()).required(10L); + } + @Test void reviewCannotChangeDirectionEvenBeforePurchase() { + var order = order(OverseasProcurementService.PENDING_REVIEW,0,0); + order.setProcurementDirection(ProcurementDirection.EXPORT); + when(orders.lockNo("100","HC1")).thenReturn(order); + var error=assertThrows(ResponseStatusException.class, () -> service.review("100","HC1","7", + new OverseasProcurementRequests.Review(0L,"APPROVE","change-direction","checked",10L,ProcurementDirection.IMPORT))); + assertEquals("审核不能修改采购方向,请按客户下单方向选择采购仓",error.getReason()); + assertEquals(ProcurementDirection.EXPORT,order.getProcurementDirection()); + } } -- 2.54.0 From 9f7cac22b36ceb921fdbe86bf30b8d0dcb038e53 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 15:38:11 +0800 Subject: [PATCH 06/70] test(lastleg): wire storage rent gate in merged picking fixture --- .../dispatch/service/DispatchMergedRelabelPersistenceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 326a23dbc..c8efa81e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -215,7 +215,7 @@ class DispatchMergedRelabelPersistenceTest { assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero(); } - /** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ + /** 隔离测量、车队及仓租门禁;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ private static final class Merged { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchService rules=new DispatchService();final DispatchPickingService picking;final DispatchPdaQueryService pda; @@ -261,6 +261,7 @@ class DispatchMergedRelabelPersistenceTest { ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins); picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json); ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); + ReflectionTestUtils.setField(picking,"storageRent",mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins); balance(601,"101",5);position(101,601,"101",crossCustomer?2:5); if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);} -- 2.54.0 From c6bc3ac85b823d19af56826a17e448f08a214fb7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 15:54:43 +0800 Subject: [PATCH 07/70] test(lastleg): specify departed scope for database pagination check --- .../com/baiyue/lastleg/service/LastlegOrderServiceTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 205a90d5b..bea732115 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -424,9 +424,10 @@ class LastlegOrderServiceTest { } @Test - void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { + void departedPageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null, - null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null); + null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, + LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(3, 25); resultPage.setTotal(0); when(orders.selectPage(any(), any())).thenReturn(resultPage); @@ -438,6 +439,7 @@ class LastlegOrderServiceTest { verify(orders).selectPage(page.capture(), any()); assertEquals(3, page.getValue().getCurrent()); assertEquals(25, page.getValue().getSize()); + verifyNoInteractions(preDeparture); } -- 2.54.0 From 5b53be23e86362d849b15deee0989ed3be3914c5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 16:45:36 +0800 Subject: [PATCH 08/70] fix(finance): show direct-paid costs as fully paid --- .../finance/service/FinanceQueryService.java | 7 +- .../FinanceDirectPaidCostReadTest.java | 89 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java 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 691d46130..9d41b2eb9 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -219,6 +219,7 @@ public class FinanceQueryService { row.outstandingAmount(), row.currency(), row.status(), row.version())).toList(); } + /** 成本查询按来源付款事实回显金额,直接已付不能再次展示为待付款。 */ public List payableSettlements(Long customerId) { validateOptionalCustomer(customerId); String sql = "SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE 1=1"; @@ -227,9 +228,13 @@ public class FinanceQueryService { return jdbc.query(sql + " ORDER BY created_at DESC,id DESC", (rs, rowNum) -> { BigDecimal amount = rs.getBigDecimal("cost_amount"); + boolean directPaid = "SOURCE_DIRECT_PAID".equals(rs.getString("cost_type")); + // 已确认直付成本已全额付款;作废历史不再计入有效已付,也不形成新应付。 + BigDecimal paid = directPaid && "CONFIRMED".equals(rs.getString("status")) ? amount : BigDecimal.ZERO; + BigDecimal outstanding = directPaid ? BigDecimal.ZERO : amount; return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"), - rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"), + rs.getString("fee_item_name"), amount, paid, outstanding, rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at"), rs.getInt("version")); }, args); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java b/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java new file mode 100644 index 000000000..11c995fd1 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java @@ -0,0 +1,89 @@ +package com.baiyue.finance; + +import com.baiyue.finance.service.FinanceQueryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** 成本列表和详情须与直接已付事实一致,不能提示供应商费用仍待支付。 */ +class FinanceDirectPaidCostReadTest { + private JdbcTemplate jdbc; + private FinanceQueryService service; + + /** 使用隔离内存表验证正式读取路径,不访问共享测试环境。 */ + @BeforeEach + void setUp() { + jdbc = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:cost-read-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_cost(id BIGINT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(128)," + + "cost_type VARCHAR(24),cost_origin VARCHAR(24),order_id BIGINT,inbound_id BIGINT,container_id BIGINT," + + "supplier_name_snapshot VARCHAR(128),fee_item_name VARCHAR(128),cost_amount DECIMAL(18,2)," + + "currency_code VARCHAR(3),status VARCHAR(24),created_at TIMESTAMP,version INT)"); + jdbc.execute("CREATE TABLE fin_cost_allocation(id BIGINT,tenant_id BIGINT,cost_id BIGINT,order_id BIGINT," + + "inbound_id BIGINT,volume_cbm DECIMAL(18,6),total_volume_cbm DECIMAL(18,6),allocation_rate DECIMAL(18,6)," + + "allocated_amount DECIMAL(18,2),rounding_adjustment BOOLEAN)"); + service = new FinanceQueryService(jdbc, new ObjectMapper()); + } + + /** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */ + @ParameterizedTest + @ValueSource(strings = {"BUSINESS_DIRECT_PAID", "FIRSTLEG_DIRECT_PAID"}) + void confirmedDirectPaidCostIsFullyPaidInListAndDetail(String origin) { + insertCost(1, 11, "SOURCE_DIRECT_PAID", origin, "CONFIRMED"); + + var row = service.payableSettlements(11L).get(0); + assertThat(row.payableAmount()).isEqualByComparingTo("0.03"); + assertThat(row.paidAmount()).isEqualByComparingTo("0.03"); + assertThat(row.outstandingAmount()).isZero(); + assertThat(service.cost(11, 1).cost()).isEqualTo(row); + } + + /** 已作废的直付历史保留原金额,但不再作为有效已付或待付款展示。 */ + @Test + void voidedDirectPaidCostHasNoActivePaymentOrOutstandingAmount() { + insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "VOIDED"); + + var row = service.payableSettlements(11L).get(0); + assertThat(row.payableAmount()).isEqualByComparingTo("0.03"); + assertThat(row.paidAmount()).isZero(); + assertThat(row.outstandingAmount()).isZero(); + assertThat(row.status()).isEqualTo("VOIDED"); + } + + /** 人工补充成本没有直接已付事实,不能被自动视为付款完成。 */ + @ParameterizedTest + @ValueSource(strings = {"SINGLE_ORDER", "CONTAINER_SHARED"}) + void manualCostKeepsItsExistingUnpaidAmounts(String type) { + insertCost(1, 11, type, "MANUAL", "CONFIRMED"); + + var row = service.payableSettlements(11L).get(0); + assertThat(row.paidAmount()).isZero(); + assertThat(row.outstandingAmount()).isEqualByComparingTo("0.03"); + } + + /** 金额回显修复不能让其他客户读到成本列表或详情。 */ + @Test + void costReadsKeepCustomerIsolation() { + insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "CONFIRMED"); + + assertThat(service.payableSettlements(12L)).isEmpty(); + assertThatThrownBy(() -> service.cost(12, 1)).hasMessageContaining("成本不存在或不属于客户"); + } + + /** 仅构造测试所需的成本事实,金额对应本轮发现问题的小额样本。 */ + private void insertCost(long id, long customer, String type, String origin, String status) { + jdbc.update("INSERT INTO fin_cost(id,tenant_id,cost_no,cost_type,cost_origin,order_id,container_id," + + "supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version) " + + "VALUES(?,?,?,?,?,101,201,'验收供应商','人工费',0.03,'CNY',?,CURRENT_TIMESTAMP,0)", + id, customer, "COST-READ-TEST", type, origin, status); + } +} -- 2.54.0 From 6577b2aa03d2289468e0559dc621a5f05de785df Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 17:25:26 +0800 Subject: [PATCH 09/70] test(lastleg): isolate photo table DDL from subsequent rent migrations --- .../fleet/photo/FleetResourcePhotoPersistenceTest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoPersistenceTest.java index 55cc5c79d..ff90849ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoPersistenceTest.java @@ -15,10 +15,15 @@ import static org.assertj.core.api.Assertions.*; /** 使用真实建表定义与Mapper验证类型隔离、最新版本及不可变历史。 */ class FleetResourcePhotoPersistenceTest { + /** 仅装载照片表定义,避免执行同文件后续的其他业务迁移。 */ @Test void preservesHistoryAndRejectsTwoWritesToSameVersion() throws Exception { var source=new JdbcDataSource();source.setURL("jdbc:h2:mem:photos_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); var schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - var ddl=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (")); + int start=schema.indexOf("CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo ("); + assertThat(start).isGreaterThanOrEqualTo(0); + int end=schema.indexOf(';',start); + assertThat(end).isGreaterThan(start); + var ddl=schema.substring(start,end+1); try(var connection=source.getConnection();var statement=connection.createStatement()) { statement.execute(ddl); } var config=new Configuration(new Environment("photos",new JdbcTransactionFactory(),source)); config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetResourcePhotoMapper.class); -- 2.54.0 From 2927fbee06c8286abb850aa73ea82bd092eb4eed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 17:27:32 +0800 Subject: [PATCH 10/70] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20261022-海外费用仓租数据库变更汇总.md | 9 +- deploy/sql/README.md | 2 + ...erseas-fees-storage-rent-structure-add.sql | 97 ++++++++++++++++ .../overseas-storage-rent-zero-fee-repair.sql | 2 +- .../overseas-fees-storage-rent-release.md | 23 +++- .../mapper/DispatchCandidateMapper.java | 6 +- .../dispatch/mapper/DispatchSourceMapper.java | 4 +- .../controller/InboundPlanController.java | 17 +++ .../dto/ConfirmInboundShortageRequest.java | 15 +++ .../pda/repository/PdaWorkflowRepository.java | 6 + .../service/InboundPdaWorkflowService.java | 66 ++++++++++- .../service/InboundStockReceiptService.java | 17 ++- .../service/ReceivingContainerService.java | 4 +- .../workflow/OrderWorkflowFactMapper.java | 2 +- .../openapi/overseas-business-v1.yaml | 26 +++++ .../DispatchCandidateMapperContractTest.java | 50 +++++---- ...DispatchInboundReceiptPersistenceTest.java | 2 + .../DispatchPlanPdaReadPersistenceTest.java | 2 +- .../controller/InboundPlanControllerTest.java | 13 +++ .../InboundShortageConfirmationTest.java | 106 ++++++++++++++++++ .../InboundStockReceiptServiceTest.java | 28 +++++ ...ema-alignment-migrations-contract.test.mjs | 53 +++++++++ 22 files changed, 512 insertions(+), 38 deletions(-) create mode 100644 deploy/sql/overseas-fees-storage-rent-structure-add.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java diff --git a/deploy/sql/20261022-海外费用仓租数据库变更汇总.md b/deploy/sql/20261022-海外费用仓租数据库变更汇总.md index 74645a113..357741d93 100644 --- a/deploy/sql/20261022-海外费用仓租数据库变更汇总.md +++ b/deploy/sql/20261022-海外费用仓租数据库变更汇总.md @@ -11,7 +11,14 @@ 两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `CREATE TABLE` 中,菜单及授权使用初始化种子;不追加存量改造、权限版本回填或动态迁移段。目标 schema 必须为空,按文件原有初始化流程执行;不能向已有业务库重放。 -本次不再提供这项功能的独立升级脚本。已有库升级必须按实际结构和明确授权另行准备、审阅受控增量,不能把删除重复文件理解为已有库已完成升级。 +已有库只按实际缺项执行定向结构补齐,不能把删除重复文件理解为已有库已完成升级。指定 TEST 的最小执行集合如下;文件准备不代表获得执行授权或已部署。 + +| 顺序 | 文件 | 允许的变更与核对结果 | +|---|---|---| +| 1 | [表与审核人字段补齐](overseas-fees-storage-rent-structure-add.sql) | 只补 `ll_storage_rent_inbound`、`ll_finance_fee_source.reviewer_name`;已存在且匹配时跳过,同名异构则停止;成功回查表结构、字段并输出 `TABLE_COLUMN_READY` | +| 2 | [免费仓租定向修复](overseas-storage-rent-zero-fee-repair.sql) | 仅自动应收仓租允许原币/结算双零与空汇率;保留其他费用正额及有效汇率约束,保留已有汇率精度与数值默认值;最终核对 `SHOW CREATE TABLE` 中金额、汇率两项 CHECK 和汇率可空性 | + +两步均固定 `baiyue_lastleg`,使用独立连接、支持 `DELIMITER` 的客户端,禁止遇错继续;不执行业务 DML、不补历史计租记录、不变更余额、汇率配置或权限。DDL 隐式提交,不能宣称整体可回滚;失败保留错误并只读复查,已完成项不删除重建。两步是已核缺项的补齐方案,不是任意版本升级工具;重新执行前仍需核对目标实例与结构。 ## 海外表和字段 diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 5674b6160..64eecf602 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。 + 放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 diff --git a/deploy/sql/overseas-fees-storage-rent-structure-add.sql b/deploy/sql/overseas-fees-storage-rent-structure-add.sql new file mode 100644 index 000000000..1db557726 --- /dev/null +++ b/deploy/sql/overseas-fees-storage-rent-structure-add.sql @@ -0,0 +1,97 @@ +-- 指定 TEST 的海外费用/仓租结构补齐;实例必须由执行者另行核对并获准。 +-- 仅新增 ll_storage_rent_inbound 和 ll_finance_fee_source.reviewer_name。 +-- 使用独立连接,支持 DELIMITER,遇错停止;禁止 mysql --force。 +-- 不更新费用、库存、钱包或历史计租数据,不重放空库初始化。 +-- DDL 隐式提交;失败先回查结构,禁止删除已建表/字段来重跑。 +USE baiyue_lastleg; + +-- 仅清理本脚本可能遗留的专用校验过程,不删除业务对象。 +DROP PROCEDURE IF EXISTS overseas_fee_rent_structure_add_20261026; +DELIMITER $$ +CREATE PROCEDURE overseas_fee_rent_structure_add_20261026() +BEGIN + DECLARE inbound_exists INT DEFAULT 0; + DECLARE reviewer_exists INT DEFAULT 0; + + IF DATABASE()<>'baiyue_lastleg' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg'; + END IF; + -- 在首个业务 DDL 前核对两张依赖表及新增字段的定位列。 + IF (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_finance_fee_source','ll_storage_rent_lot') + AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB')<>2 + OR (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' + AND COLUMN_NAME='reviewer_id' AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=128)<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用来源或计租批次基础结构不匹配,停止补齐'; + END IF; + + SELECT COUNT(*) INTO reviewer_exists FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name'; + IF reviewer_exists>0 AND (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name' + AND DATA_TYPE='varchar' AND CHARACTER_MAXIMUM_LENGTH=255 AND IS_NULLABLE='YES' + AND COLUMN_DEFAULT IS NULL AND EXTRA='')<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的审核人名称字段与正式定义不同,停止且不覆盖'; + END IF; + + SELECT COUNT(*) INTO inbound_exists FROM information_schema.TABLES + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'; + -- 已存在的同名表必须完整匹配;不以 IF NOT EXISTS 掩盖半成品或异构表。 + IF inbound_exists>0 THEN + IF (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_unicode_ci')<>1 + OR (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4 + OR (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA='' + AND ((COLUMN_NAME IN ('receipt_id','lot_id') AND COLUMN_TYPE='bigint') + OR (COLUMN_NAME='quantity' AND COLUMN_TYPE='decimal(24,12)') + OR (COLUMN_NAME='created_at' AND COLUMN_TYPE='datetime(3)')))<>4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已存在的计租实收表字段与正式定义不同,停止且不覆盖'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY')<>1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' AND INDEX_NAME='PRIMARY' + AND COLUMN_NAME='receipt_id' AND SEQ_IN_INDEX=1 AND NON_UNIQUE=0 AND SUB_PART IS NULL)<>1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND INDEX_NAME='idx_storage_rent_inbound_lot')<>1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' + AND INDEX_NAME='idx_storage_rent_inbound_lot' AND COLUMN_NAME='lot_id' + AND SEQ_IN_INDEX=1 AND NON_UNIQUE=1 AND SUB_PART IS NULL)<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='计租实收表主键或批次索引不匹配,停止且不覆盖'; + END IF; + END IF; + + -- 全部前置检查通过后才补缺项;重跑时保持已存在的业务结构和数据。 + IF reviewer_exists=0 THEN + ALTER TABLE ll_finance_fee_source + ADD COLUMN reviewer_name VARCHAR(255) NULL COMMENT '审核人名称快照' AFTER reviewer_id; + END IF; + IF inbound_exists=0 THEN + CREATE TABLE ll_storage_rent_inbound ( + receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量', + lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键', + quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数', + created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时', + PRIMARY KEY (receipt_id), + KEY idx_storage_rent_inbound_lot (lot_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记'; + END IF; +END$$ +DELIMITER ; +CALL overseas_fee_rent_structure_add_20261026(); +DROP PROCEDURE overseas_fee_rent_structure_add_20261026; + +SHOW CREATE TABLE ll_storage_rent_inbound; +SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='reviewer_name'; +SELECT 'TABLE_COLUMN_READY' AS structure_status; +-- TABLE_COLUMN_READY 只表示上述表/字段补齐;免费仓租另执行已审阅的 +-- overseas-storage-rent-zero-fee-repair.sql,不能据此宣称完整功能上线。 diff --git a/deploy/sql/overseas-storage-rent-zero-fee-repair.sql b/deploy/sql/overseas-storage-rent-zero-fee-repair.sql index 6eadb4cd1..904fe09bc 100644 --- a/deploy/sql/overseas-storage-rent-zero-fee-repair.sql +++ b/deploy/sql/overseas-storage-rent-zero-fee-repair.sql @@ -1,4 +1,4 @@ --- 固定目标本机 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。 +-- 固定目标经单独授权的 TEST 或本机开发 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。 -- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。 USE baiyue_lastleg; SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS diff --git a/lastleg-service/docs/overseas-fees-storage-rent-release.md b/lastleg-service/docs/overseas-fees-storage-rent-release.md index 32c1a4759..94e232e1f 100644 --- a/lastleg-service/docs/overseas-fees-storage-rent-release.md +++ b/lastleg-service/docs/overseas-fees-storage-rent-release.md @@ -38,10 +38,27 @@ - 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。 - 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。 - 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。 -- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql) 仅解决自动仓租双零金额和空汇率入费用源表,保留人工正额与正常费用有效汇率约束,不更新业务数据。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。 +- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。指定 TEST 先使用[表与审核人字段补齐](../../deploy/sql/overseas-fees-storage-rent-structure-add.sql),再使用[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql);精确范围与失败语义只维护在[数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。 - 新空库:Lastleg 源基线与海外统一初始化脚本同步新表和字段;国内统一基线同步菜单。菜单查询权限 `platform:overseas-fee:list`,编辑与审核分别沿用 `platform:finance:fee-source:edit/review`,仅有查询权限不能写费用。 - 配置:核对 Firstleg、Warehouse 内部服务地址及内部令牌、客户仓租阶梯、有效 USD/CNY 月度汇率、Finance 资金与费用事件直投及回执配置。当前结算支持 USD、CNY,CNY 按1:1结算,其他币种必须先调整为受支持币种。不得在材料中记录密钥。 -- 发布顺序:先发布 Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。 +- 发布顺序:先发布 Firstleg 公共费用目录、Warehouse 客户计租接口与 Finance,再发布 Lastleg,配套更新 Admin 与海外 PDA。成功扣款引用新增可选的 `walletBusinessType/walletBusinessNo`,分别指向人工费用冻结编号或仓租出库预占编号;外层业务编号仍是实际服务单。Finance 校验客户、金额、成功命令、冻结与流水一致且不可重复关联,接收后标记已结清。旧版 Finance 不识别这两个字段,不得先发生产者。 + +### TEST 配套发布清单 + +本清单用于准备和审阅,不表示环境已完成执行。实际版本、部署结果及未关闭验收项仍只维护于[测试环境主契约](../../../docs/测试环境单机部署与配置契约.md)。发布前冻结后端、Admin、海外 PDA 的精确提交和产物摘要;执行期间任一版本变化都需重新核对受影响项,不使用浮动分支头替代审阅版本。 + +| 顺序 | 对象 | 执行前提与通过标准 | +|---|---|---| +| 1 | 规则与执行窗口 | 确认人工应收是否开放“推送结算/余额扣费”选择:当前前后端已支持显式选择,未选择才按业务默认规则;本文件首版固定分流描述不能作为该选择功能已获确认的依据。明确本批 TEST SQL、服务发布及测试客户写入授权,切换期间暂停相关收费与出库操作 | +| 2 | Lastleg 结构 | 按数据库变更汇总审阅并执行两个定向文件,核对新增表/字段及免费仓租约束;不重新初始化已有资金、计租表,不重放已有权限种子,不改现行汇率 | +| 3 | Firstleg、Warehouse、Finance | 用匹配后端提交分别发布,核对容器镜像与健康;Firstleg 具备 `/internal/firstleg/v1/fee-items`,Warehouse 具备客户计租 `/pricing`,Finance 接受应收结算方式及独立扣款引用;内部服务地址、统一服务令牌及事件回传配置匹配 | +| 4 | Lastleg | 依赖就绪后发布费用/仓租编排及收发货挂钩;统一 Gateway 下费用分页、项目目录、仓租列表与仓库选项均返回业务成功,不能只检查 HTTP 200 | +| 5 | Admin、海外 PDA | Admin 包含海外费用管理和单据费用操作;海外 PDA 包含 `/pda/v1/last-leg/storage-rent/preparations` 出库预校验。H5 按宿主机静态站点规则发布并核验资源摘要;APK/WGT 按实际设备另行核对,H5 通过不等于设备已更新 | +| 6 | 最小业务验收 | 使用明确授权的新测试样本完成下表;逐笔核对业务单、费用、冻结/消费/退款流水及事件回执。查询成功、表为空或历史事件已投递不能替代新功能闭环 | + +第一轮最小样本按以下顺序执行:人工余额收费的登记冻结、审核实扣、原请求重放与驳回释放;账单收费审核入池且钱包不变;免费仓租记录无冻结/扣款;非零仓租出库预占、审核前调价及审核实扣;头程同单同仓补入库不重置库龄、重复实收不增加计租数量。供应商账期/直付与采购专用资金链继续按下表分别验收,不由人工收费样本推定通过。 + +若结构、服务、页面或设备任一项不匹配,保持相关收费/出库操作暂停并记录失败,不通过补零、伪造回执或重建费用继续;DDL 不做自动回滚,服务回滚仅限明确核对过的同协议同 Schema 版本。页面布局由人工按 1920×1080、100% 缩放提供截图核对。 ## 实际环境验收 @@ -65,7 +82,7 @@ ## 验证记录 -本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调与 PDA 设备验收尚未执行。上线前逐项填写环境、版本、执行时间与证据,未通过项不得标成通过。 +本地验证结果在本次交付说明中列出;真实环境 SQL、部署、HTTP 联调及 PDA 验收状态统一维护在[测试环境单机部署与配置契约](../../../docs/测试环境单机部署与配置契约.md)。该主契约逐项记录环境、版本、执行时间与证据,未通过项不得标成通过。 2026-10-22 本地验证记录(使用最终受影响文件;复用已通过且输入未变的结果): diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 23968c094..161483e0c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -11,6 +11,8 @@ import java.util.List; public interface DispatchCandidateMapper { /** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */ String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))"; + /** 短少确认后没有实收的预留托盘不再作为在途货候选,避免继续排出不存在的箱数。 */ + String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')"; /** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */ String DEPARTED_PLAN = "EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + @@ -178,7 +180,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -283,7 +285,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index e2a2baf4b..f035d1da9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -33,7 +33,7 @@ public interface DispatchSourceMapper { FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id - WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL + WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 @@ -115,7 +115,7 @@ public interface DispatchSourceMapper { /** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */ @Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id " - + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')") + + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')") int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id); /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index ef92711ee..88f56a6ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -38,6 +38,23 @@ public class InboundPlanController { @Resource private InboundPlanService service; @Resource private ContainerInboundPlanConversionCoordinator containerConversion; @Resource private InboundFbaAppointmentService appointmentService; + @Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; + + /** 平台核对现场短少后按实收入库,不修改原应收数量。 */ + @PostMapping("/{planNo}/confirm-shortage") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + public ApiResponse confirmShortage( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少"); + } + String customerId = convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request)); + } /** 按入仓计划明细分页查询可预约的入库订单行。 */ @GetMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java new file mode 100644 index 000000000..50bd4ff5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */ +public record ConfirmInboundShortageRequest( + @NotBlank String customerId, + @NotNull @Min(0) Integer version, + @NotNull @Min(1) Integer expectedPackageCount, + @NotNull @Min(1) Integer receivedPackageCount, + @NotBlank @Size(max = 200) String reason +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index dbd9b4615..2a5ede8dc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -36,6 +36,12 @@ public class PdaWorkflowRepository { @Resource private InboundPalletMergeMapper merges; @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + @Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts; + + /** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */ + public List skuReceipts(String customerId, Long planId) { + return skuReceipts.listPlan(customerId, planId); + } /** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */ public InboundPlan findPlan(String customerId, String planNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index bcd469f2a..7bbac0829 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -716,7 +716,71 @@ public class InboundPdaWorkflowService { repository.update(plan); } - /** Explicit operator decision to receive only the cartons actually picked. */ + /** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */ + @Transactional + public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId, + String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128 + || operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) { + throw conflict("短少确认必须提供操作人、请求键和确认原因"); + } + InboundPlan original = repository.findPlan(customerId, planNo); + if (original == null) throw notFound("入库单不存在或不属于当前客户"); + dispatchReceipts.lock(List.of(original.getId())); + InboundPlan plan = requirePlan(customerId, planNo); + // 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。 + Map command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request); + InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null + || !"PENDING_RECONCILIATION".equals(task.getStatus())) { + throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试"); + } + if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少"); + if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单"); + if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) { + throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少"); + } + List lines = repository.lines(plan.getId()); + List allocations = repository.allocations(plan.getId()); + int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) { + throw conflict("应收或实收数量已变化,请刷新后重新确认"); + } + if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少"); + var skuReceipts = repository.skuReceipts(customerId, plan.getId()); + for (InboundPlanLine line : lines) { + int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId())) + .mapToInt(a -> value(a.getPickedPackageCount())).sum(); + int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId())) + .mapToInt(r -> value(r.getReceivedPackages())).sum(); + if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) { + throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认"); + } + } + if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0 + || value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) { + throw conflict("清点与收货数量不一致,不能确认短少"); + } + List pallets = new ArrayList<>(repository.pallets(plan.getId())); + Set palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet()); + pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId) + .filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet()))); + // 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。 + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true); + task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan); + dispatchReceipts.complete(List.of(plan.getId()), operatorId, now); + PdaResponses.Task result = taskResponse(task, plan, planNo); + saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result); + return result; + } + + /** 操作员明确选择仅将当前已收箱数入库。 */ @Transactional public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) { InboundPlan plan = requirePlan(customerId, planNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 1c1106b18..dd77ddf57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -50,6 +50,7 @@ public class InboundStockReceiptService { @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; + /** 按完整实收生成库存凭证。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -57,6 +58,7 @@ public class InboundStockReceiptService { stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); } + /** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -105,7 +107,7 @@ public class InboundStockReceiptService { List exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); if (!exactSkuReceipts.isEmpty()) { created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, - palletById, order, exactSkuReceipts, pickedPackages, receivedAt)); + palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial)); continue; } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) @@ -145,14 +147,17 @@ public class InboundStockReceiptService { completeOrders(customerId, plan, orderById, receivedAt, allowPartial); } + /** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */ private List persistExactSkuReceipts(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, List lineAllocations, Map palletById, LastlegOrder order, - List exactReceipts, long pickedPackages, LocalDateTime receivedAt) { + List exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) { + if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank() + || value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数"); long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total"); - Map expectedBySku = skuRemainders(order.getId(), pickedPackages).stream() + Map expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream() .collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum, java.util.LinkedHashMap::new)); Map actualBySku = exactReceipts.stream().collect(Collectors.groupingBy( @@ -161,7 +166,11 @@ public class InboundStockReceiptService { Map normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap( value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum, java.util.LinkedHashMap::new)); - if (!actualBySku.equals(normalizedExpected)) { + boolean invalid = allowPartial + ? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey()) + || entry.getValue() > normalizedExpected.get(entry.getKey())) + : !actualBySku.equals(normalizedExpected); + if (invalid) { throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 949763110..71619b2f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -738,7 +738,7 @@ public class ReceivingContainerService { boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; boolean allPlansCompleted = !matchedPlans.isEmpty() - && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") + && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = facts == null ? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1 @@ -812,7 +812,7 @@ public class ReceivingContainerService { if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { throw conflict("Every inbound plan in the receiving container must have an owning customer"); } - if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION") + if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(value.getStatus()))) { throw conflict("Receiving container is already completed"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java index dc8dc34e8..058efb950 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java @@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper { WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address) AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering, - EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded + EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER') AND (o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey}) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index e23c85b8b..8e04a22ae 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -667,6 +667,32 @@ paths: responses: '200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } } '404': { description: 入库订单不存在或不属于指定客户 } + /api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage: + post: + summary: 确认FBA入库短少并按实收入库 + description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。 + security: [{ bearerAuth: [] }] + parameters: + - { name: planNo, in: path, required: true, schema: { type: string } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId, version, expectedPackageCount, receivedPackageCount, reason] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + version: { type: integer, minimum: 0 } + expectedPackageCount: { type: integer, minimum: 1 } + receivedPackageCount: { type: integer, minimum: 1 } + reason: { type: string, minLength: 1, maxLength: 200 } + responses: + '200': { description: ApiResponse包装的收货任务结果,status为PARTIAL_INBOUND } + '403': { description: 非平台身份或无入库编辑权限 } + '404': { description: 入库单不存在或客户不匹配 } + '409': { description: 版本、状态、实收数量、SKU或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/manual: post: summary: 平台按单票手工生成入库计划 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 5b0cdbba1..5a8e83e91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -16,12 +16,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { - /** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */ + /** 执行正式候选和提交资格,覆盖在途预排、收货结束后的空预留托盘及真实库存。 */ @Test void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { var db = connection.createStatement(); - db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')"); db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); @@ -29,8 +29,8 @@ class DispatchCandidateMapperContractTest { db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)"); db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)"); db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); - for (int id = 1; id <= 9; id++) { - db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')"); + for (int id = 1; id <= 14; id++) { + db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')"); db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); @@ -44,24 +44,34 @@ class DispatchCandidateMapperContractTest { db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)"); + // 同一计划原分两托,最终21箱全收在一托;另一预留托盘不能继续冒充可预排货物。 + db.execute("UPDATE ll_inbound_plan SET status='COMPLETED' WHERE id IN (10,13,14)"); + db.execute("UPDATE ll_inbound_plan_pallet SET plan_id=10,assignment_status='ASSIGNED',actual_package_count=21 WHERE id=11"); + db.execute("INSERT INTO ll_inventory_position VALUES ('A',111,21)"); + db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12"); + db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13"); + db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14"); String plan = DispatchCandidateMapper.PLAN_ONLY; - String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) " - + "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " - + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))"; - var ids = new ArrayList(); - try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," - + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " - + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { - while (rows.next()) { - long id = rows.getLong("id"); ids.add(id); - assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage")); - assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class)); - } - } - assertEquals(List.of(1L, 3L, 7L), ids); - // 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。 + // 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。 for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { - assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id"))); + int start = sql.indexOf("AND (NOT " + plan + " OR ("); + int stockGate = sql.indexOf("AND (" + plan + " OR EXISTS", start); + int end = sql.indexOf("AND NOT EXISTS (SELECT 1 FROM ll_", stockGate); + String eligible = sql.substring(start, end).replace(">", ">") + .replace("#{customerId}", "'A'"); + var ids = new ArrayList(); + try (var rows = db.executeQuery("SELECT p.id,p.actual_package_count,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," + + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " + + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { + while (rows.next()) { + long id = rows.getLong("id"); ids.add(id); + assertEquals(id == 3 || id == 11 ? "RECEIVED" : "PLAN", rows.getString("stage")); + assertEquals(id == 3 || id == 11 ? 100L + id : null, rows.getObject("physical", Long.class)); + if (id == 11) assertEquals(21, rows.getInt("actual_package_count")); + } + } + // 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。 + assertEquals(List.of(1L, 3L, 7L, 11L), ids); assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java index 412c304be..73a542ec7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -53,6 +53,8 @@ class DispatchInboundReceiptPersistenceTest { db.update("DELETE FROM ll_order_detail WHERE customer_id='101'"); assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull(); assertThat(mapper.confirmedPlan("other",1)).isEmpty(); + db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); assertThat(mapper.confirmedPlan("101",1)).isEmpty(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index d116164cb..0e44c61cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest { f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')"); var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class); assertThat(mapper.receivedPlanCompleted("101",2L)).isZero(); - for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) { + for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) { f.f.db.update("UPDATE ll_inbound_plan SET status=?",status); assertThat(mapper.receivedPlanCompleted("101",2L)).isOne(); assertThat(mapper.receivedPlanCompleted("other",2L)).isZero(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index c34060f95..3912dbdfd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -31,6 +31,7 @@ class InboundPlanControllerTest { @Mock InboundPlanService service; @Mock InboundFbaAppointmentService appointmentService; @Mock ContainerInboundPlanConversionCoordinator containerConversion; + @Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; private InboundPlanController controller; @BeforeEach @@ -39,6 +40,18 @@ class InboundPlanControllerTest { ReflectionTestUtils.setField(controller, "service", service); ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); ReflectionTestUtils.setField(controller, "containerConversion", containerConversion); + ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow); + } + + /** 短少确认只允许平台指定具体客户,并保留真实操作人。 */ + @Test void platformConfirmsShortageWithExplicitCustomer() { + var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少"); + controller.confirmShortage(platform(), "IP-1", "key", request); + verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request); + var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class); + var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少"); + assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java new file mode 100644 index 000000000..4341dffa3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.pda.entity.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InboundShortageConfirmationTest { + @Mock PdaWorkflowRepository repository; + @Mock InboundStockReceiptService stock; + @Mock InboundDispatchReceiptGateway dispatch; + @Mock InboundUnloadCompletedOutboxService outbox; + InboundPdaWorkflowService service; + InboundPlan plan; + InboundPdaTask task; + InboundPlanLine line; + InboundPlanPalletAllocation allocation; + final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0); + + /** 准备已结束现场收货、应收21实收20的冻结事实。 */ + @BeforeEach void setup() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "stockReceipts", stock); + ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch); + ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100"); + plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4); + task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100"); + task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt); + task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt); + line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21); + allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L); + allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20); + var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20); + when(repository.findPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockTask("100", 1L)).thenReturn(task); + lenient().when(repository.lines(1L)).thenReturn(List.of(line)); + lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation)); + lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku)); + } + + /** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */ + @Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() { + var request = request(4, 20); + var result = service.confirmShortage("100", "IP-1", "admin", "key", request); + assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount()); + assertEquals(receivedAt, task.getCompletedAt()); + var sequence = inOrder(dispatch, repository, stock); + sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1"); + sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true)); + sequence.verify(dispatch).complete(eq(List.of(1L)), eq("admin"), any()); + var audit = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(audit.capture()); + assertTrue(audit.getValue().getRequestJson().contains("现场确认短少1箱")); + when(repository.operation("100", "key")).thenReturn(audit.getValue()); + assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request)); + verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true)); + verifyNoInteractions(outbox); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request)); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + } + + /** 页面旧版本或旧实收数不能确认库存。 */ + @Test void rejectsStaleVersionAndCounts() { + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20))); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + verifyNoInteractions(stock); + } + + /** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */ + @Test void rejectsMissingSkuReceipts() { + when(repository.skuReceipts("100", 1L)).thenReturn(List.of()); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock); + } + + /** 客户归属不匹配时不触发任何库存或运输操作。 */ + @Test void rejectsWrongCustomer() { + when(repository.findPlan("100", "IP-1")).thenReturn(null); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock, dispatch); + } + + /** 当前实收值随请求一起参与版本及幂等校验。 */ + private ConfirmInboundShortageRequest request(int version, int received) { + return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index cc62bf9de..461c1de86 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -325,6 +325,34 @@ class InboundStockReceiptServiceTest { List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10))); } + /** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */ + @Test void exactSkuShortageStocksOnlyActualPackages() { + Fixture f = fixture(21, 21, 20, 210); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20))); + when(inventory.stockIn(eq("100"), eq("operator"), any())) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false)); + service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true); + var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("100"), eq("operator"), request.capture()); + assertEquals(20, request.getValue().packages()); + assertEquals(21, f.detail.getPackageCount()); + assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus()); + } + + /** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */ + @Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() { + Fixture f = fixture(21, 21, 20, 210); + f.detail.setPackageCount(10); + when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110))); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 11), skuReceipt(12, 21, "SKU-2", 9))); + assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true)); + verify(inventory, never()).stockIn(any(), any(), any()); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index b96f69e3a..99f4819f9 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -6,6 +6,59 @@ import { fileURLToPath } from 'node:url' import { buildPlan, loadScopes } from '../deploy/sql/scripts/db-migrations.mjs' const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) + +const overseasFeeStructurePath = 'deploy/sql/overseas-fees-storage-rent-structure-add.sql' +const readSql = relative => readFile(path.join(repositoryRoot, relative), 'utf8') +const withoutSqlComments = source => source.replace(/^\s*--.*$/gm, '') + +test('海外费用结构补齐只修改已核准的表字段,不包含业务数据写入', async () => { + const source = withoutSqlComments(await readSql(overseasFeeStructurePath)) + assert.match(source, /USE baiyue_lastleg;/) + assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE|REPLACE|TRUNCATE)\b/i) + assert.doesNotMatch(source, /\bDROP\s+(?:TABLE|DATABASE|COLUMN|INDEX)\b/i) + assert.deepEqual([...source.matchAll(/^\s*ALTER TABLE\s+(\w+)/gim)].map(match => match[1]), ['ll_finance_fee_source']) + assert.deepEqual([...source.matchAll(/^\s*CREATE TABLE\s+(\w+)/gim)].map(match => match[1]), ['ll_storage_rent_inbound']) + assert.match(source, /ADD COLUMN reviewer_name VARCHAR\(255\) NULL COMMENT '审核人名称快照' AFTER reviewer_id;/) +}) + +test('海外费用计租实收表与两份正式基线完全一致', async () => { + const files = [overseasFeeStructurePath, 'lastleg-service/src/main/resources/db/lastleg.sql', 'deploy/sql/海外区域统一增量-MySQL8.sql'] + const definitions = await Promise.all(files.map(async file => { + const source = await readSql(file) + const definition = source.match(/CREATE TABLE(?: IF NOT EXISTS)? ll_storage_rent_inbound\s*\([\s\S]*?\) ENGINE=[^;]+;/)?.[0] + assert.ok(definition, file) + assert.ok(/reviewer_name\s+VARCHAR\(255\)\s+(?:COLLATE\s+\w+\s+)?(?:DEFAULT\s+)?NULL\s+COMMENT '审核人名称快照'/i.test(source), `${file} 审核人名称须为可空 VARCHAR(255)`) + return definition.replace(' IF NOT EXISTS', '').replace(/\s+/g, ' ').toLowerCase() + })) + assert.equal(definitions[0], definitions[1]) + assert.equal(definitions[0], definitions[2]) +}) + +test('海外费用结构补齐先检查同名结构再修改,支持匹配结构重跑', async () => { + const source = withoutSqlComments(await readSql(overseasFeeStructurePath)) + const firstDdl = source.indexOf('ALTER TABLE ll_finance_fee_source') + assert.ok(firstDdl > 0) + const guards = source.slice(0, firstDdl) + for (const marker of ["DATABASE()<>'baiyue_lastleg'", 'reviewer_exists>0', 'inbound_exists>0', "TABLE_COLLATION='utf8mb4_unicode_ci'", "COLUMN_TYPE='decimal(24,12)'", "COLUMN_TYPE='datetime(3)'", "INDEX_NAME='PRIMARY'", "INDEX_NAME='idx_storage_rent_inbound_lot'"]) { + assert.ok(guards.includes(marker), marker) + } + assert.match(guards, /SIGNAL SQLSTATE '45000'/) + assert.match(source, /IF reviewer_exists=0 THEN\s+ALTER TABLE/) + assert.match(source, /IF inbound_exists=0 THEN\s+CREATE TABLE/) + assert.match(source, /CALL overseas_fee_rent_structure_add_20261026\(\);[\s\S]+SHOW CREATE TABLE ll_storage_rent_inbound;[\s\S]+'TABLE_COLUMN_READY'/) +}) + +test('海外费用免费仓租修复只放行自动应收双零,保留其他费用及汇率边界', async () => { + const repair = await readSql('deploy/sql/overseas-storage-rent-zero-fee-repair.sql') + const normalized = repair.replaceAll("''", "'") + const autoZero = "amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE'" + assert.ok(normalized.includes(`CHECK(amount>0 OR (${autoZero}))`)) + assert.ok(normalized.includes(`CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND ${autoZero}))`)) + assert.match(repair, /SELECT COLUMN_TYPE,COLUMN_COMMENT,COLUMN_DEFAULT INTO rate_type,rate_comment,rate_default/) + assert.match(repair, /rate_default IS NOT NULL AND rate_default NOT REGEXP/) + assert.doesNotMatch(withoutSqlComments(repair), /^\s*(?:INSERT|UPDATE|DELETE|REPLACE|TRUNCATE)\b/im) +}) + const migrations = { iam: 'deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql', iamForwardFix: 'deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql', -- 2.54.0 From f6d99919551175dca308ee079356755cb210ca5c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 18:52:13 +0800 Subject: [PATCH 11/70] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/BusinessFileCategory.java | 2 +- .../fleet-owned-business-photos-v1.yaml | 2 +- .../file/service/BusinessFileStorageTest.java | 6 ++ .../service/DispatchAggregateService.java | 6 +- .../service/DispatchMasterPhotoService.java | 58 +++++++++++++++ .../gateway/HttpOwnedBusinessFileGateway.java | 15 ++-- .../port/DispatchLoadingPhotoPort.java | 4 + .../service/DispatchLoadingPhotoAdapter.java | 34 ++++++++- .../service/OwnedBusinessPhotoService.java | 2 +- .../dispatch-loading-documents-v1.yaml | 14 +++- .../openapi/fleet-outsourced-business-v1.yaml | 5 +- .../openapi/fleet-owned-business-v1.yaml | 5 +- .../DispatchAggregatePersistenceTest.java | 5 +- ...spatchLoadingDeparturePersistenceTest.java | 74 ++++++++++++++++++- .../DispatchMasterPhotoServiceTest.java | 50 +++++++++++++ ...DispatchReviewWorkflowPersistenceTest.java | 17 +++++ .../OwnedBusinessFileGatewayTest.java | 15 +++- 17 files changed, 288 insertions(+), 26 deletions(-) diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 5de589c09..fe85e01e9 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -15,7 +15,7 @@ enum BusinessFileCategory { OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), - OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml index f1c9fd9f9..532fddedb 100644 --- a/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml +++ b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml @@ -7,7 +7,7 @@ info: 复用InternalBusinessFileController,不增加公网File路径。 businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO, tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。 - 仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 + 装车资料接受非空JPG/PNG/PDF(Lastleg仅允许司机驾驶证类别使用PDF),油耗仍仅接受JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、 分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。 File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。 diff --git a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java index a08d53383..64d8492df 100644 --- a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java @@ -22,6 +22,12 @@ import static org.mockito.Mockito.when; class BusinessFileStorageTest { @TempDir Path root; + @Test void loadingLicenseStoresPdfButFuelStillRejectsPdf() { + var file=new MockMultipartFile("file","license.pdf","application/pdf","%PDF-1.4\n%%EOF".getBytes()); + var stored=storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_LOADING_PHOTO); + assertThat(stored.contentType()).isEqualTo("application/pdf"); + assertThatThrownBy(()->storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_FUEL_PHOTO)).isInstanceOf(BizException.class); + } @Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() { BusinessFileStorage storage = storage(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 3addb7c67..de28eb959 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -452,6 +452,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) { if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id); } + masterPhotos.invalidateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class),savedResources); row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); // 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。 @@ -504,7 +505,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments()); if(loading) { row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); - if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name()); + if(request.decision()==Decision.APPROVED) { + row.setStage(Stage.LOADING.name()); + if(!"PICKUP".equals(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class)); + } } else { row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound()))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java index d0bc8d25b..9037a60ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java @@ -8,6 +8,11 @@ import org.springframework.web.multipart.MultipartFile; import java.io.*; import java.nio.file.Files; import java.util.List; +import java.util.ArrayList; +import java.util.Objects; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER; @@ -16,10 +21,63 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER public class DispatchMasterPhotoService { private final FleetResourcePhotoPort photos; private final DispatchEvidenceGateway evidence; + @jakarta.annotation.Resource private DispatchLoadingPhotoPort loadingPhotos; + @jakarta.annotation.Resource private FleetTripExecutionService fleet; /** 只读主档并通过本车文件网关创建证据。 */ public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) { this.photos=photos; this.evidence=evidence; } + /** 资源保存时比较本趟资料来源;来源变化后旧类别不能继续充当新资源的凭证。 */ + public void invalidateLoading(long dispatchId,String operator,Resources before,Resources after) { + var slots=java.util.EnumSet.noneOf(Slot.class); + if(!Objects.equals(vehicleSource(before.vehicle()),vehicleSource(after.vehicle())))slots.add(Slot.LOADING_LIST); + if(!Objects.equals(driverSource(before.driver()),driverSource(after.driver())))slots.add(Slot.LOADING_SCENE); + if(!slots.isEmpty())loadingPhotos.invalidateResourcePhotos(dispatchId,operator,slots); + } + /** 只比较车辆身份及照片,不因容量或运营状态变更废弃现场资料。 */ + private Object vehicleSource(VehicleSnapshot value) { + return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate(),sourceFiles(value.evidenceFileIds())); + } + /** 临时司机同样比较身份与证照;人像、电话和预计结束时间不属于驾驶证来源。 */ + private Object driverSource(DriverSnapshot value) { + if(value==null)return null; + var evidence=value.evidence(); + return java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),evidence==null?null:evidence.licenseNo(), + evidence==null?null:sourceFiles(evidence.fileIds()),evidence==null?null:evidence.masterLicenseFileId()); + } + /** 空附件与未提供附件等价,调整顺序不改变资料来源。 */ + private java.util.Set sourceFiles(List ids) { return ids==null?java.util.Set.of():java.util.Set.copyOf(ids); } + /** 理货通过后把已审核的本趟快照复制进装车资料;不读取后来变更的主档、不复制司机人像。 */ + public void populateLoading(long dispatchId,String operator,Resources value) { + var saved=loadingPhotos.read(dispatchId).photos(); + var driver=value.driver(); + if(driver!=null && driver.evidence()!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_SCENE)) { + var files=new ArrayList(); + for(var id:driver.evidence().fileIds().stream().distinct().limit(5).toList()) { + if(driver.mode()==MASTER && driver.driverId()!=null && Objects.equals(id,driver.evidence().masterLicenseFileId())) { + var source=fleet.dispatchDriverLicense(driver.driverId(),id); + files.add(new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content())); + } else files.add(snapshot(dispatchId,DispatchEvidenceGateway.Kind.DRIVER,id)); + } + loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_SCENE,files); + } + var vehicle=value.vehicle(); + if(vehicle!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_LIST)) { + var files=new ArrayList(); + for(var id:vehicle.evidenceFileIds().stream().distinct().toList()) { + var metadata=evidence.metadata(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id); + if(!List.of("image/jpeg","image/png").contains(metadata.contentType()))continue; + files.add(new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id))); + if(files.size()==5)break; + } + loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_LIST,files); + } + } + /** 复制前再次核验本趟及证据分类,不把外部文件编号直接绑定为装车资料。 */ + private MultipartFile snapshot(long dispatchId,DispatchEvidenceGateway.Kind kind,long id) { + var metadata=evidence.metadata(dispatchId,kind,id); + return new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,kind,id)); + } /** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */ public Resources populate(long dispatchId,String operator,Resources value) { var v=value.vehicle(); var d=value.driver(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java index a962aa57b..72eec6693 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java @@ -28,15 +28,15 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway { client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token; } /** MIME和字节签名一起校验,File仍执行其权威校验。 */ - public static void validate(Upload upload) { + public static void validate(PhotoSlot slot,Upload upload) { if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255 || upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE - || !image(upload.contentType()) || !signature(upload.contentType(),upload.content())) - throw bad("照片须为非空真实JPG/PNG,最大10MiB"); + || !accepted(slot,upload.contentType()) || !signature(upload.contentType(),upload.content())) + throw bad("资料须为真实JPG/PNG;司机驾驶证也支持PDF,最大10MiB"); } /** 使用原图片字节,未知结果不能换键或自动重传。 */ @Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) { - validate(upload);var body=new MultipartBodyBuilder(); + validate(slot,upload);var body=new MultipartBodyBuilder(); body.part("file",new ByteArrayResource(upload.content()){ /** 保留本轮原文件名,不生成占位证据。 */ @Override public String getFilename(){return upload.fileName();} @@ -64,15 +64,16 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway { var m=response.data(); if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType()) || !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId()) - || m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType()) + || m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !accepted(slot,m.contentType()) || m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效"); return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString()); } - /** 不放开PDF、SVG或任意image类型。 */ - private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);} + /** 仅驾驶证类别接受PDF,其他装车及油耗类别仍只接受JPG/PNG。 */ + private static boolean accepted(PhotoSlot slot,String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type) || slot==PhotoSlot.LOADING_SCENE && "application/pdf".equals(type);} /** 使用真实文件头,不转换或伪造证据图片。 */ private static boolean signature(String type,byte[] bytes) { if(bytes==null)return false; + if("application/pdf".equals(type))return bytes.length>=5 && bytes[0]=='%' && bytes[1]=='P' && bytes[2]=='D' && bytes[3]=='F' && bytes[4]=='-'; if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255; byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length files); + /** 已持排车父锁;资源变化使对应当前资料失效,旧文件与版本历史保留。 */ + void invalidateResourcePhotos(long dispatchId,String operator,java.util.Set slots); /** 上传保存原请求意图;不提前计入已保存照片。 */ Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file); /** 仅原操作者可恢复原上传结果。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java index ba1ec9b42..7f9320fb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java @@ -31,6 +31,38 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files; } public record Binding(Slot slot,BindPhoto request) {} + /** 与资源修改同事务失效旧凭证;不变类别及历史保留,旧资料版本不得继续保存。 */ + @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public void invalidateResourcePhotos(long id,String operator,Set slots) { + if(!Set.of(Slot.LOADING_SCENE,Slot.LOADING_LIST).containsAll(slots))throw bad("仅可失效车辆或司机资料"); + var row=repository.record(id); + if(row==null || slots.isEmpty())return; + row=repository.lock(row); + var current=projection.photos(row); + var kept=current.stream().filter(photo->slots.stream().noneMatch(slot->photo.slot()==slot(slot))).toList(); + if(kept.size()==current.size())return; + long version=row.getRecordVersion(); + row.setPhotosJson(codec.write(kept));row.setRecordVersion(Math.addExact(version,1)); + row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); + if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); + var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); + history.setAction("INVALIDATE_RESOURCE_PHOTOS");history.setOperatorId(operator);history.setReason("车辆或司机资料变化,原凭证失效");history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),kept,operator)));repository.history(history); + } + /** 自动带入只填空类;重复审核及已有现场照片不重复追加或覆盖。 */ + @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public void importIfEmpty(long id,String operator,Slot slot,List sources) { + var state=read(id); + if(state.photos().stream().anyMatch(photo->photo.slot()==slot) || sources.isEmpty())return; + if(sources.size()>5)throw bad("每类装车资料最多5份"); + for(var source:sources) { + try { + var payload=new OwnedBusinessFileGateway.Upload(source.getOriginalFilename(),source.getContentType(),source.getBytes()); + var uploaded=files.upload(id,slot(slot),operator,payload); + state=bind(id,operator,slot,new BindPhoto(state.recordVersion(),UUID.randomUUID().toString(),Long.toString(uploaded.fileId()))); + } catch(IOException failure) { throw bad("读取本趟资料失败,未完成带入"); } + } + } /** GET只读;首次保存前资料版本为0。 */ @Override public State read(long id) { var row=repository.record(id); @@ -43,7 +75,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); - HttpOwnedBusinessFileGateway.validate(payload); + HttpOwnedBusinessFileGateway.validate(originalSlot,payload); var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); var reservation=commands.reserveUpload(id,operator(operator),key,identity); if(!reservation.created())return uploaded(reservation.command()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java index 0a6074143..de21ab92f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java @@ -43,7 +43,7 @@ public class OwnedBusinessPhotoService { if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");} var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); - HttpOwnedBusinessFileGateway.validate(payload); + HttpOwnedBusinessFileGateway.validate(slot,payload); var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); var reservation=commands.reserveUpload(source.getId(),actor,key,identity); if(!reservation.created()) { diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml index c791d967c..8205cab9c 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 排车装车资料(Admin/PDA共用) version: 1.0.0 - description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 + description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史,重审通过后带入新资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 security: - bearerAuth: [] paths: @@ -84,7 +84,7 @@ paths: file: type: string format: binary - description: 非空JPG/PNG,最大10MiB + description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB idempotencyKey: type: string minLength: 8 @@ -124,7 +124,7 @@ paths: file: type: string format: binary - description: 非空JPG/PNG,最大10MiB + description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB idempotencyKey: type: string minLength: 8 @@ -204,6 +204,10 @@ paths: schema: type: string format: binary + application/pdf: + schema: + type: string + format: binary image/png: schema: type: string @@ -225,6 +229,10 @@ paths: schema: type: string format: binary + application/pdf: + schema: + type: string + format: binary image/png: schema: type: string diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml index 0a1cce063..58a4d0894 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -75,7 +75,7 @@ paths: type: object required: [file, idempotencyKey] properties: - file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" } + file: { type: string, format: binary, description: "非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,签名校验,最大10MiB;固定装车File分类" } idempotencyKey: { type: string, minLength: 8, maxLength: 128 } responses: "200": @@ -110,6 +110,7 @@ paths: description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。" content: image/png: { schema: { type: string, format: binary } } + application/pdf: { schema: { type: string, format: binary } } image/jpeg: { schema: { type: string, format: binary } } /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}: parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] @@ -202,7 +203,7 @@ components: category: { type: string, enum: [LOADING] } fileId: { type: string } fileName: { type: string } - contentType: { type: string, enum: [image/png, image/jpeg] } + contentType: { type: string, enum: [image/png, image/jpeg, application/pdf] } sizeBytes: { type: string } uploadedAt: { type: string, format: date-time } savedAt: { type: string, format: date-time, nullable: true } diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index c2171cddb..201a973f8 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -255,7 +255,7 @@ paths: file: type: "string" format: "binary" - description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。" + description: "真实JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,非空,最大10MiB;与独立绑定命令使用不同请求键。" "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}": parameters: - @@ -334,6 +334,8 @@ paths: schema: type: "string" format: "binary" + "application/pdf": + schema: { type: string, format: binary } "image/png": schema: type: "string" @@ -642,6 +644,7 @@ components: enum: - "image/jpeg" - "image/png" + - "application/pdf" sizeBytes: type: "string" pattern: "^[1-9][0-9]*$" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 864c60a93..911417afe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -642,7 +642,10 @@ public class DispatchAggregatePersistenceTest { org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence); var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class); when(profilePhotos.current(any(),anyLong())).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(0,null)); - org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,dispatchEvidence)); + var masterPhotos=org.mockito.Mockito.spy(new DispatchMasterPhotoService(profilePhotos,dispatchEvidence)); + org.springframework.test.util.ReflectionTestUtils.setField(masterPhotos,"loadingPhotos",mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class)); + org.mockito.Mockito.doNothing().when(masterPhotos).populateLoading(anyLong(),anyString(),any()); + org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",masterPhotos); org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership); org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index 51e0a1ce8..e05023b01 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -30,6 +30,68 @@ import static org.mockito.ArgumentMatchers.*; /** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */ class DispatchLoadingDeparturePersistenceTest { + /** 零扫码退回后换资源,旧凭证失效与资源保存原子提交;重审自动带入新资料供两端共用。 */ + @Test void changedResourcesInvalidateOldDocumentsAndReapprovalImportsNewEvidence() throws Exception { + authorize();var f=new Fixture(false);long id=f.f.repository.find(f.number).getId(); + for(int i=0;i<5;i++)f.bind(i,f.f.actor); + var old=f.photos.read(id);assertThat(DispatchLoadingDocumentService.complete(old.photos())).isTrue(); + var master=(DispatchMasterPhotoService)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"masterPhotos"); + org.springframework.test.util.ReflectionTestUtils.setField(master,"loadingPhotos",f.photos); + doCallRealMethod().when(master).populateLoading(anyLong(),anyString(),any()); + var evidence=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"evidence"); + when(evidence.download(anyLong(),any(),anyLong())).thenReturn(new byte[]{1,2,3}); + var nextId=new java.util.concurrent.atomic.AtomicLong(3000); + when(f.files.upload(eq(id),any(),eq(f.f.actor.operatorId()),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(nextId.incrementAndGet(),"new.png","image/png",3,"2026-10-26T00:00:00Z")); + var returned=f.f.tx.execute(s->f.f.service.returnToReview(f.f.actor,f.number,new DispatchCommands.ReturnToReview(f.version,"return-change-resources","更换车辆司机"))); + var input=f.f.configured("new-resources",List.of());var evidenceInput=input.driver().evidence(); + var vehicle=new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"NEW-TRUCK","新承运商",input.vehicle().capacity(),List.of(111L)); + var driver=new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"新司机","987654321",new DriverEvidence("NEW-LICENSE",evidenceInput.licenseExpiresOn(),List.of(222L),evidenceInput.expectedTaskEndAt(),List.of(333L))); + var request=new DispatchCommands.Update(returned.recordVersion(),"change-resources",vehicle,driver,input.plannedDepartureAt(),null,null,null); + var previousResources=f.f.repository.find(f.number).getResourcesJson(); + assertThatThrownBy(()->f.f.tx.execute(s->{f.f.service.update(f.f.actor,f.number,request);throw new IllegalStateException("模拟后续保存失败");})).hasMessage("模拟后续保存失败"); + assertThat(f.photos.read(id)).isEqualTo(old); + assertThat(f.f.repository.find(f.number).getResourcesJson()).isEqualTo(previousResources); + var updated=f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)); + var invalidated=f.photos.read(id);assertThat(invalidated.recordVersion()).isEqualTo("6"); + assertThat(invalidated.photos()).containsExactlyElementsOf(old.photos().subList(2,5)); + assertThat(DispatchLoadingDocumentService.complete(invalidated.photos())).isFalse(); + assertThat(f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)).replayed()).isTrue(); + assertThat(f.photos.read(id)).isEqualTo(invalidated); + assertThat(f.f.db.queryForObject("SELECT snapshot_json FROM ll_fleet_owned_business_history WHERE dispatch_id=? AND record_version=5",String.class,id)).contains("1000","1001"); + f.f.prepareAppointmentNumbers(f.number); + var submitted=f.f.tx.execute(s->f.f.service.submitReview(f.f.actor,f.number,new DispatchCommands.SubmitReview(updated.recordVersion(),"resubmit-changed",null))); + var reviewed=f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch-changed",Decision.APPROVED,null,null))); + f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); + var admin=f.documents.get(f.f.actor,f.number);var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number); + assertThat(admin.photos()).isEqualTo(pda.photos()).hasSize(5);assertThat(admin.complete()).isTrue(); + assertThat(admin.photos().stream().map(p->p.fileId())).containsExactlyInAnyOrder("3001","3002","1002","1003","1004"); + verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,222L); + verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,111L); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","stale-before-change","1000")))).hasMessageContaining("其他操作更新"); + f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); + verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any()); + } + @Test void importedFilesAreSharedAcrossActorsAndCountTowardDepartureWithoutDuplicateCopies() throws Exception { + authorize();var f=new Fixture();long id=f.f.repository.find(f.number).getId(); + var image=new org.springframework.mock.web.MockMultipartFile("file","plate.png","image/png",new byte[]{1,2,3}); + when(f.files.upload(eq(id),any(),eq("operator"),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence( + call.getArgument(1)==com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot.LOADING_SCENE?2000:2001,"copied.png","image/png",3,"2026-10-26T00:00:00Z")); + f.f.tx.executeWithoutResult(s->{ + f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image)); + f.photos.importIfEmpty(id,"operator",Slot.LOADING_LIST,List.of(image)); + }); + var admin=f.documents.get(f.f.actor,f.number); + var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number); + assertThat(pda.photos()).isEqualTo(admin.photos()).hasSize(2); + assertThat(admin.complete()).isFalse();assertThat(admin.recordVersion()).isEqualTo("2"); + f.f.tx.executeWithoutResult(s->f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image))); + verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any()); + assertThat(f.photos.read(id).recordVersion()).isEqualTo("2"); + f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete-imported"))); + f.bind(2,f.f.actor);f.bind(3,f.f.actor); + assertThat(f.bind(4,new Actor("picker",ActorMode.PDA,"9")).stage()).isEqualTo("DELIVERING"); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString()); + } /** 每例恢复隔离身份,不影响其他服务权限测试。 */ @AfterEach void clearSecurity(){SecurityContextHolder.clearContext();} @@ -221,11 +283,15 @@ class DispatchLoadingDeparturePersistenceTest { final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class); final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest; final List measurements; + final OwnedBusinessFileGateway files=mock(OwnedBusinessFileGateway.class); + final DispatchLoadingPhotoAdapter photos; /** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */ - Fixture() throws Exception { + Fixture() throws Exception { this(true); } + /** 零扫码夹具用于退回重审;其余用例继续真实拣货。 */ + Fixture(boolean picked) throws Exception { var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5))))); number=created.dispatchNo();version=f.approve(created).recordVersion(); - f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); + if(picked) {f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");} f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash(); measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList(); @@ -259,9 +325,9 @@ class DispatchLoadingDeparturePersistenceTest { f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";")); } var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class)); - var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class); + var codec=new OwnedBusinessSupport(f.json); when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z")); - var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); + photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos); documents=new DispatchLoadingDocumentService(f.service,service,photos); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java index 11822f660..d7b4b0e65 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java @@ -14,6 +14,56 @@ import static org.mockito.Mockito.*; /** 自动带入不复用跨排车文件ID,也不覆盖已有当趟照片。 */ class DispatchMasterPhotoServiceTest { + /** 只失效来源变化的类别;相同来源重审、无关资料修改和文件重排保留现场替换。 */ + @Test void resourceChangesInvalidateOnlyAffectedCategories() { + var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class); + org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading); + var vehicle=new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L,102L)); + var driver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(8L),null,List.of(90L),8L)); + var original=new Resources(vehicle,driver,null); + service.invalidateLoading(9,"op",original,new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST","新承运商","IN_SERVICE",new Capacity(10,null,null),List.of(102L,101L)), + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","新电话",new DriverEvidence("LICENSE",null,List.of(8L),java.time.Instant.now(),List.of(91L),8L)),null)); + verifyNoInteractions(loading); + var changedVehicle=new VehicleSnapshot(ResourceMode.MASTER,3L,"OTHER",null,"IN_SERVICE",null,List.of(103L)); + service.invalidateLoading(9,"op",original,new Resources(changedVehicle,driver,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + clearInvocations(loading); + var renewedDriver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(9L),null,List.of(90L),9L)); + service.invalidateLoading(9,"op",original,new Resources(vehicle,renewedDriver,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE)); + clearInvocations(loading); + service.invalidateLoading(9,"op",original,new Resources(null,null,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE, + com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + } + @Test void importsFrozenLicenseAndVehicleIntoSharedLoadingCategoriesWithoutDriverPortrait() throws Exception { + var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class); + var fleet=mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class); + org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading); + org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",fleet); + when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("0",List.of())); + var license=new com.baiyue.lastleg.fleet.driver.DriverModels.File("8","license.pdf","application/pdf",8); + when(fleet.dispatchDriverLicense(2,8)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(license,"%PDF-1.4".getBytes())); + when(evidence.metadata(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new DispatchEvidenceGateway.File(101L,"plate.png","image/png",3)); + when(evidence.download(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new byte[]{1,2,3}); + var resources=new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L)), + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence(null,null,List.of(8L),null,List.of(102L),8L)),null); + service.populateLoading(9,"op",resources); + @SuppressWarnings("unchecked") var files=(ArgumentCaptor>)(ArgumentCaptor)ArgumentCaptor.forClass(List.class); + verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE),files.capture()); + assertThat(files.getValue()).hasSize(1);assertThat(files.getValue().get(0).getBytes()).isEqualTo("%PDF-1.4".getBytes()); + verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST),files.capture()); + assertThat(files.getValue().get(0).getOriginalFilename()).isEqualTo("plate.png"); + verify(evidence,never()).download(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyLong()); + verifyNoInteractions(photos); + var slots=com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.values(); + var saved=java.util.Arrays.stream(slots).map(slot->new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Photo(slot,"LOADING","900"+slot.ordinal(),"saved.png","image/png","3",null,null)).toList(); + when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("5",saved)); + clearInvocations(loading,fleet,evidence); + service.populateLoading(9,"op",resources); + verify(loading,never()).importIfEmpty(anyLong(),anyString(),any(),anyList()); + verifyNoInteractions(fleet,evidence); + } final FleetResourcePhotoService photos=mock(FleetResourcePhotoService.class); final DispatchEvidenceGateway evidence=mock(DispatchEvidenceGateway.class); final DispatchMasterPhotoService service=new DispatchMasterPhotoService(photos,evidence); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index 8bd3e1731..e4b118ed4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -11,6 +11,23 @@ import static org.assertj.core.api.Assertions.*; /** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ class DispatchReviewWorkflowPersistenceTest { + @Test void approvedLoadingReviewImportsOnceAndCopyFailureRollsBackApproval() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("copy-create",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=submit(f,created,"copy-submit"); + var approved=review(f,f.actor,submitted,false,"copy-dispatch",Decision.APPROVED,null); + var master=org.mockito.Mockito.mock(DispatchMasterPhotoService.class); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"masterPhotos",master); + org.mockito.Mockito.doThrow(new IllegalStateException("文件服务不可用")).doNothing().when(master).populateLoading(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); + assertThatThrownBy(()->review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null)).hasMessageContaining("文件服务不可用"); + assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("BEFORE_LOADING"); + assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("LOADING_PENDING"); + var loaded=review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null); + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + org.mockito.Mockito.clearInvocations(master); + assertThat(review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null).recordVersion()).isEqualTo(loaded.recordVersion()); + org.mockito.Mockito.verifyNoInteractions(master); + } /** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */ @Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java index 90b749a63..352720f4d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java @@ -11,9 +11,18 @@ import static org.junit.jupiter.api.Assertions.*; class OwnedBusinessFileGatewayTest { /** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */ @Test void invalidImageSignaturesAndMimeAreRejected() { - assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes()))); - assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes()))); - assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0]))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0]))); + } + @Test void onlyDriverLicenseAcceptsPdfAndChecksMetadataScope() { + var pdf=new OwnedBusinessFileGateway.Upload("license.pdf","application/pdf","%PDF-1.4".getBytes()); + assertDoesNotThrow(()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,pdf)); + for(var slot:PhotoSlot.values())if(slot!=PhotoSlot.LOADING_SCENE) + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(slot,pdf)); + var meta=new HttpOwnedBusinessFileGateway.Metadata(9L,0L,"OVERSEAS_DISPATCH_LOADING_PHOTO","1","license.pdf","application/pdf",8L,LocalDateTime.now()); + assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_SCENE,9L).fileId()); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_LIST,9L)); } /** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */ @Test void metadataCannotCrossCustomerDispatchOrCategory() { -- 2.54.0 From a5befc9bad7b10a9b63481277d231dad014145ce Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 19:21:11 +0800 Subject: [PATCH 12/70] fix(dispatch): invalidate resource evidence and bind uploads to review rounds --- .../DispatchLoadingDocumentController.java | 4 +- .../PdaDispatchLoadingDocumentController.java | 4 +- .../DispatchLoadingDocumentService.java | 11 ++-- .../service/DispatchMasterPhotoService.java | 12 ++++ .../port/DispatchLoadingPhotoPort.java | 10 ++-- .../service/DispatchLoadingPhotoAdapter.java | 29 +++++++--- .../dispatch-loading-documents-v1.yaml | 22 +++++++- ...spatchLoadingDeparturePersistenceTest.java | 55 ++++++++++++++----- .../DispatchMasterPhotoServiceTest.java | 11 +++- ...ross-customer-acceptance-contract.test.mjs | 2 +- ...rnal-dispatch-acceptance-contract.test.mjs | 2 +- ...rn-redispatch-acceptance-contract.test.mjs | 2 +- .../fleet-trip-fulfillment-contract.test.mjs | 9 +-- .../fleet-trip-fulfillment.mjs | 8 ++- scripts/dispatch-loading-flow-test-env.mjs | 8 ++- 15 files changed, 138 insertions(+), 51 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java index 3adc9d1aa..8422ec02e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java @@ -32,8 +32,8 @@ public class DispatchLoadingDocumentController { @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, - @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { - return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file)); + @RequestParam @Positive long reviewRound,@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,reviewRound,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java index 229ea7140..efcae4d71 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java @@ -32,8 +32,8 @@ public class PdaDispatchLoadingDocumentController { @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, - @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { - return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file)); + @RequestParam @Positive long reviewRound,@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,reviewRound,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java index e49905832..8feab3034 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java @@ -30,8 +30,11 @@ public class DispatchLoadingDocumentService { require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true)); } /** 上传先记原请求意图,不提前绑定或推进派送。 */ - public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) { - require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file); + public Photo upload(Actor actor,String number,Slot slot,long reviewRound,String key,MultipartFile file) { + require(actor,true);var context=context(actor,number,false); + if(reviewRound<=0 || reviewRound!=context.reviews().loadingReview().reviewRound()) + throw new BizException(ResultCode.CONFLICT,"审核轮次已变化,请刷新后重新选择资料"); + return photos.upload(context.dispatchId(),actor.operatorId(),slot,reviewRound,key,file); } /** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */ public Photo recover(Actor actor,String number,String key) { @@ -42,7 +45,7 @@ public class DispatchLoadingDocumentService { public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) { require(actor,true);var context=context(actor,number,true); var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay; - var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request); + var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,context.reviews().loadingReview().reviewRound(),request); if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) { departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number); } @@ -64,7 +67,7 @@ public class DispatchLoadingDocumentService { } /** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */ private Documents documents(Context context,State state,boolean editable) { - return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos()); + return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),Long.toString(context.reviews().loadingReview().reviewRound()),context.stage().name(),editable,complete(state.photos()),state.photos()); } /** 按五个固定框核齐,不能用总数或重复槽位代替。 */ static boolean complete(List photos) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java index 9037a60ec..977277655 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java @@ -32,8 +32,20 @@ public class DispatchMasterPhotoService { var slots=java.util.EnumSet.noneOf(Slot.class); if(!Objects.equals(vehicleSource(before.vehicle()),vehicleSource(after.vehicle())))slots.add(Slot.LOADING_LIST); if(!Objects.equals(driverSource(before.driver()),driverSource(after.driver())))slots.add(Slot.LOADING_SCENE); + if(!Objects.equals(vehicleIdentity(before.vehicle()),vehicleIdentity(after.vehicle()))) { + slots.add(Slot.PRE_DEPARTURE_CHECK);slots.add(Slot.DRIVER_LICENSE); + } + if(!Objects.equals(driverIdentity(before.driver()),driverIdentity(after.driver())))slots.add(Slot.RECEIPT); if(!slots.isEmpty())loadingPhotos.invalidateResourcePhotos(dispatchId,operator,slots); } + /** 换车必须重新记录装车和封车现场;同车补图不影响现场凭证。 */ + private Object vehicleIdentity(VehicleSnapshot value) { + return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate()); + } + /** 签字对应实际司机身份,不能在换司机后沿用。 */ + private Object driverIdentity(DriverSnapshot value) { + return value==null?null:java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),value.evidence()==null?null:value.evidence().licenseNo()); + } /** 只比较车辆身份及照片,不因容量或运营状态变更废弃现场资料。 */ private Object vehicleSource(VehicleSnapshot value) { return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate(),sourceFiles(value.evidenceFileIds())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java index ab70715e2..4fdd57c3c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java @@ -9,12 +9,12 @@ public interface DispatchLoadingPhotoPort { enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT } record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId, - @Pattern(regexp="[1-9][0-9]*") String replaceFileId) { + @Pattern(regexp="[1-9][0-9]*") String replaceFileId,@NotBlank @Size(min=8,max=128) String uploadKey) { /** 未指定替换目标时追加照片,每类最多五张。 */ - public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); } + public BindPhoto(String recordVersion,String idempotencyKey,String fileId,String uploadKey) { this(recordVersion,idempotencyKey,fileId,null,uploadKey); } } record State(String recordVersion,List photos) {} - record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List photos) {} + record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String reviewRound,String stage,boolean editable,boolean complete,List photos) {} record Download(Photo photo,byte[] content) {} /** 只读取原整车附加资料版本与五框,不初始化记录。 */ State read(long dispatchId); @@ -23,13 +23,13 @@ public interface DispatchLoadingPhotoPort { /** 已持排车父锁;资源变化使对应当前资料失效,旧文件与版本历史保留。 */ void invalidateResourcePhotos(long dispatchId,String operator,java.util.Set slots); /** 上传保存原请求意图;不提前计入已保存照片。 */ - Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file); + Photo upload(long dispatchId,String operator,Slot slot,long reviewRound,String key,MultipartFile file); /** 仅原操作者可恢复原上传结果。 */ Photo recover(long dispatchId,String operator,String key); /** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */ Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request); /** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */ - State bind(long dispatchId,String operator,Slot slot,BindPhoto request); + State bind(long dispatchId,String operator,Slot slot,long reviewRound,BindPhoto request); /** 与绑定及自动发车共同提交完整原结果。 */ void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result); /** 文件只可从本车当前已绑定槽位下载。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java index 7f9320fb3..4318a76ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java @@ -34,7 +34,6 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { /** 与资源修改同事务失效旧凭证;不变类别及历史保留,旧资料版本不得继续保存。 */ @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) public void invalidateResourcePhotos(long id,String operator,Set slots) { - if(!Set.of(Slot.LOADING_SCENE,Slot.LOADING_LIST).containsAll(slots))throw bad("仅可失效车辆或司机资料"); var row=repository.record(id); if(row==null || slots.isEmpty())return; row=repository.lock(row); @@ -59,7 +58,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { try { var payload=new OwnedBusinessFileGateway.Upload(source.getOriginalFilename(),source.getContentType(),source.getBytes()); var uploaded=files.upload(id,slot(slot),operator,payload); - state=bind(id,operator,slot,new BindPhoto(state.recordVersion(),UUID.randomUUID().toString(),Long.toString(uploaded.fileId()))); + state=savePhoto(id,operator,slot,codec.version(state.recordVersion()),uploaded.fileId(),null); } catch(IOException failure) { throw bad("读取本趟资料失败,未完成带入"); } } } @@ -70,13 +69,14 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { .filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList()); } /** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */ - @Override public Photo upload(long id,String operator,Slot slot,String key,MultipartFile multipart) { + @Override public Photo upload(long id,String operator,Slot slot,long reviewRound,String key,MultipartFile multipart) { codec.key(key);var originalSlot=slot(slot); + if(reviewRound<=0)throw bad("审核轮次无效"); if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); HttpOwnedBusinessFileGateway.validate(originalSlot,payload); - var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var identity=new LoadingUploadIdentity(reviewRound,new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes))); var reservation=commands.reserveUpload(id,operator(operator),key,identity); if(!reservation.created())return uploaded(reservation.command()); try { @@ -102,15 +102,27 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { } /** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */ @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) - public State bind(long id,String operator,Slot slot,BindPhoto request) { - validate(slot,request);long version=codec.version(request.recordVersion());var originalSlot=slot(slot); - var verified=photo(originalSlot,files.metadata(id,originalSlot,codec.version(request.fileId()))); + public State bind(long id,String operator,Slot slot,long reviewRound,BindPhoto request) { + validate(slot,request); + var command=commands.existing(id,request.uploadKey(),operator(operator)); + if(command==null)throw conflict("未找到本次照片的原上传记录,请重新上传"); + var uploaded=uploaded(command); + var identity=codec.read(command.getRequestJson(),LoadingUploadIdentity.class); + if(identity.reviewRound()!=reviewRound)throw conflict("照片所属审核轮次已变化,请刷新后重新上传"); + if(uploaded.slot()!=slot || !Objects.equals(uploaded.fileId(),request.fileId()))throw bad("照片与原上传记录不一致"); + return savePhoto(id,operator,slot,codec.version(request.recordVersion()),codec.version(request.fileId()),request.replaceFileId()); + } + public record LoadingUploadIdentity(long reviewRound,OwnedBusinessPhotoService.UploadIdentity file) {} + /** 自动复制与已核验上传共用保存逻辑;外部绑定必须先通过上传轮次校验。 */ + private State savePhoto(long id,String operator,Slot slot,long version,long fileId,String replaceFileId) { + var originalSlot=slot(slot); + var verified=photo(originalSlot,files.metadata(id,originalSlot,fileId)); var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id); seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); var row=repository.lock(seed); if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()); - var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId()); + var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,replaceFileId); row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); @@ -135,6 +147,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { /** 版本0合法,文件编号必须为正且请求键不得缺失。 */ private void validate(Slot slot,BindPhoto request) { slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion()); + codec.key(request.uploadKey()); if(codec.version(request.fileId())<=0)throw bad("文件编号无效"); if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效"); } diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml index 8205cab9c..f74c4aa61 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 排车装车资料(Admin/PDA共用) version: 1.0.0 - description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史,重审通过后带入新资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 + description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史;换车另失效装车完成及封车照片,换司机另失效BOL签字,重审通过后只带入驾驶证与车牌资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。上传须携当前reviewRound并将轮次写入上传账本;绑定须携uploadKey核对原上传文件、分类、操作者及当前审核轮次,不允许用最新资料版本重绑旧轮次上传。 security: - bearerAuth: [] paths: @@ -80,11 +80,16 @@ paths: required: - file - idempotencyKey + - reviewRound properties: file: type: string format: binary description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB + reviewRound: + type: string + pattern: "^[1-9][0-9]*$" + description: 选择照片时看到的审核轮次;不得在上传后改用新轮次。 idempotencyKey: type: string minLength: 8 @@ -120,11 +125,16 @@ paths: required: - file - idempotencyKey + - reviewRound properties: file: type: string format: binary description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB + reviewRound: + type: string + pattern: "^[1-9][0-9]*$" + description: 选择照片时看到的审核轮次;不得在上传后改用新轮次。 idempotencyKey: type: string minLength: 8 @@ -359,6 +369,7 @@ components: - editable - photos - dispatchRecordVersion + - reviewRound - stage - complete properties: @@ -374,6 +385,9 @@ components: $ref: '#/components/schemas/Photo' dispatchRecordVersion: type: string + reviewRound: + type: string + pattern: "^[1-9][0-9]*$" stage: type: string enum: @@ -389,11 +403,17 @@ components: required: - recordVersion - idempotencyKey + - uploadKey - fileId properties: recordVersion: type: string pattern: ^(0|[1-9][0-9]*)$ + uploadKey: + type: string + minLength: 8 + maxLength: 128 + description: 本文件原上传请求的幂等键;与当前审核轮次及操作者一致。 idempotencyKey: type: string minLength: 8 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index e05023b01..75ba5e687 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -30,11 +30,24 @@ import static org.mockito.ArgumentMatchers.*; /** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */ class DispatchLoadingDeparturePersistenceTest { + /** 绑定必须引用本人的原上传文件及分类,不能用另一张图或另一个操作者的回执充数。 */ + @Test void bindingRequiresTheOriginalUploadFileSlotAndActor() throws Exception { + authorize();var f=new Fixture(); + var request=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,"0","source-proof","2500",null); + var wrongFile=new BindPhoto("0","different-file","2501",request.uploadKey()); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,wrongFile))).hasMessageContaining("原上传记录不一致"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,request))).hasMessageContaining("原上传记录不一致"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.LOADING_SCENE,request))).hasMessageContaining("不属于当前操作账号"); + assertThat(f.documents.get(f.f.actor,f.number).photos()).isEmpty(); + assertThat(f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,request)).photos()).extracting(p->p.fileId()).containsExactly("2500"); + } /** 零扫码退回后换资源,旧凭证失效与资源保存原子提交;重审自动带入新资料供两端共用。 */ @Test void changedResourcesInvalidateOldDocumentsAndReapprovalImportsNewEvidence() throws Exception { authorize();var f=new Fixture(false);long id=f.f.repository.find(f.number).getId(); for(int i=0;i<5;i++)f.bind(i,f.f.actor); var old=f.photos.read(id);assertThat(DispatchLoadingDocumentService.complete(old.photos())).isTrue(); + var pending=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,old.recordVersion(),"pending-old-round","2500",null); + clearInvocations(f.files); var master=(DispatchMasterPhotoService)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"masterPhotos"); org.springframework.test.util.ReflectionTestUtils.setField(master,"loadingPhotos",f.photos); doCallRealMethod().when(master).populateLoading(anyLong(),anyString(),any()); @@ -53,7 +66,7 @@ class DispatchLoadingDeparturePersistenceTest { assertThat(f.f.repository.find(f.number).getResourcesJson()).isEqualTo(previousResources); var updated=f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)); var invalidated=f.photos.read(id);assertThat(invalidated.recordVersion()).isEqualTo("6"); - assertThat(invalidated.photos()).containsExactlyElementsOf(old.photos().subList(2,5)); + assertThat(invalidated.photos()).isEmpty(); assertThat(DispatchLoadingDocumentService.complete(invalidated.photos())).isFalse(); assertThat(f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)).replayed()).isTrue(); assertThat(f.photos.read(id)).isEqualTo(invalidated); @@ -63,11 +76,16 @@ class DispatchLoadingDeparturePersistenceTest { var reviewed=f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch-changed",Decision.APPROVED,null,null))); f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); var admin=f.documents.get(f.f.actor,f.number);var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number); - assertThat(admin.photos()).isEqualTo(pda.photos()).hasSize(5);assertThat(admin.complete()).isTrue(); - assertThat(admin.photos().stream().map(p->p.fileId())).containsExactlyInAnyOrder("3001","3002","1002","1003","1004"); + assertThat(admin.photos()).isEqualTo(pda.photos()).hasSize(2);assertThat(admin.complete()).isFalse(); + assertThat(admin.photos().stream().map(p->p.fileId())).containsExactlyInAnyOrder("3001","3002"); verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,222L); verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,111L); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","stale-before-change","1000")))).hasMessageContaining("其他操作更新"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","stale-before-change","1000","upload-photo-bind-0")))).hasMessageContaining("审核轮次已变化"); + var rebased=new BindPhoto(admin.recordVersion(),"rebased-old-upload",pending.fileId(),pending.uploadKey()); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,rebased))).hasMessageContaining("审核轮次已变化"); + var image=new org.springframework.mock.web.MockMultipartFile("file","old.png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10}); + assertThatThrownBy(()->f.documents.upload(f.f.actor,f.number,Slot.LOADING_SCENE,1,"stale-upload-request",image)).hasMessageContaining("审核轮次已变化"); + assertThat(f.photos.read(id).photos()).isEqualTo(admin.photos()); f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any()); } @@ -105,7 +123,7 @@ class DispatchLoadingDeparturePersistenceTest { assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING"); assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5); - var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.RECEIPT,new BindPhoto("4","photo-bind-4","1004"))); + var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.RECEIPT,new BindPhoto("4","photo-bind-4","1004","upload-photo-bind-4"))); assertThat(replayPhoto).isEqualTo(fifth); assertThat(result.counts().shippedPackageCount()).isZero();assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED"); @@ -175,7 +193,7 @@ class DispatchLoadingDeparturePersistenceTest { f.f.db.update("UPDATE ll_dispatch_order SET stage='LOADING'"); f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); assertThatThrownBy(()->f.documents.get(new Actor("picker",ActorMode.PDA,"99"),f.number)).hasMessageContaining("当前绑定仓"); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,new BindPhoto("0","wrong-slot","1000","upload-photo-bind-0")))).hasMessageContaining("装车照片框"); SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("reader","","platform:overseas-warehouse:dispatch:list")); assertThat(f.documents.get(f.f.actor,f.number).editable()).isFalse(); assertThatThrownBy(()->f.bind(0,f.f.actor)).hasMessageContaining("无装车资料操作权限"); @@ -190,7 +208,7 @@ class DispatchLoadingDeparturePersistenceTest { var results=new ArrayList>(); for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{ authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor; - f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true; + f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,f.uploadedRequest(actor,Slot.RECEIPT,"4",key,"1004",null)));return true; }catch(com.baiyue.common.core.exception.BizException expected){return false;} finally {SecurityContextHolder.clearContext();} })); @@ -249,20 +267,20 @@ class DispatchLoadingDeparturePersistenceTest { @Test void multiplePhotosPerCategoryPreserveReferencesAndDepartureGate() throws Exception { authorize();var f=new Fixture(); for(int i=0;i<5;i++) { - var request=new BindPhoto(Integer.toString(i),"multi-photo-"+i,Long.toString(2000+i)); + var request=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,Integer.toString(i),"multi-photo-"+i,Long.toString(2000+i),null); assertThat(f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,request)).complete()).isFalse(); } assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","too-many-photo","3000")))).hasMessageContaining("最多5张"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,"5","too-many-photo","3000",null)))).hasMessageContaining("最多5张"); assertThat(f.documents.get(f.f.actor,f.number).recordVersion()).isEqualTo("5"); - var replace=new BindPhoto("5","replace-photo","3000","2002"); + var replace=f.uploadedRequest(f.f.actor,Slot.LOADING_SCENE,"5","replace-photo","3000","2002"); var result=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace)); assertThat(result.photos()).extracting(p->p.fileId()).containsExactly("2000","2001","3000","2003","2004"); var replay=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace)); assertThat(replay).isEqualTo(result); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,new BindPhoto("6","wrong-photo-slot","4000","2000")))).hasMessageContaining("待替换照片已变化"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,f.uploadedRequest(f.f.actor,Slot.LOADING_LIST,"6","wrong-photo-slot","4000","2000")))).hasMessageContaining("待替换照片已变化"); for(int i=1;i<5;i++) { - var slot=DispatchLoadingDocumentService.SLOTS.get(i);var request=new BindPhoto(Integer.toString(5+i),"other-category-"+i,Long.toString(4000+i)); + var slot=DispatchLoadingDocumentService.SLOTS.get(i);var request=f.uploadedRequest(f.f.actor,slot,Integer.toString(5+i),"other-category-"+i,Long.toString(4000+i),null); f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,slot,request)); } assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(9); @@ -327,13 +345,24 @@ class DispatchLoadingDeparturePersistenceTest { var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class)); var codec=new OwnedBusinessSupport(f.json); when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z")); + when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{ + OwnedBusinessFileGateway.Upload payload=call.getArgument(3); + return new OwnedBusinessFileGateway.Evidence(Long.parseLong(payload.fileName().replace(".png","")),payload.fileName(),payload.contentType(),payload.content().length,"2026-10-26T00:00:00Z"); + }); photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos); documents=new DispatchLoadingDocumentService(f.service,service,photos); } /** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */ Documents bind(int index,Actor actor) { - return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index)))); + return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),uploadedRequest(actor,DispatchLoadingDocumentService.SLOTS.get(index),Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index),null))); + } + /** 使用真实上传账本记录来源轮次;仅隔离远程文件字节存储。 */ + BindPhoto uploadedRequest(Actor actor,Slot slot,String version,String key,String fileId,String replaceFileId) { + var file=new org.springframework.mock.web.MockMultipartFile("file",fileId+".png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10}); + String uploadKey="upload-"+key; + documents.upload(actor,number,slot,Long.parseLong(documents.get(actor,number).reviewRound()),uploadKey,file); + return new BindPhoto(version,key,fileId,replaceFileId,uploadKey); } /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,"实装发车");} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java index d7b4b0e65..f91baf14f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java @@ -26,15 +26,20 @@ class DispatchMasterPhotoServiceTest { verifyNoInteractions(loading); var changedVehicle=new VehicleSnapshot(ResourceMode.MASTER,3L,"OTHER",null,"IN_SERVICE",null,List.of(103L)); service.invalidateLoading(9,"op",original,new Resources(changedVehicle,driver,null)); - verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST, + com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.PRE_DEPARTURE_CHECK,com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.DRIVER_LICENSE)); clearInvocations(loading); var renewedDriver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(9L),null,List.of(90L),9L)); service.invalidateLoading(9,"op",original,new Resources(vehicle,renewedDriver,null)); verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE)); clearInvocations(loading); - service.invalidateLoading(9,"op",original,new Resources(null,null,null)); + var changedDriver=new DriverSnapshot(ResourceMode.MASTER,3L,"新司机","电话",new DriverEvidence("NEW-LICENSE",null,List.of(9L),null,List.of(90L),9L)); + service.invalidateLoading(9,"op",original,new Resources(vehicle,changedDriver,null)); verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE, - com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.RECEIPT)); + clearInvocations(loading); + service.invalidateLoading(9,"op",original,new Resources(null,null,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.EnumSet.allOf(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.class)); } @Test void importsFrozenLicenseAndVehicleIntoSharedLoadingCategoriesWithoutDriverPortrait() throws Exception { var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class); diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index ad72fd20a..bc483c533 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -50,7 +50,7 @@ function model(fault = '') { } return response({...prior.result,replayed:true}) } - if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()}) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') { diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs index c69001b39..25459da8f 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -87,7 +87,7 @@ function fixture(fault = '', nativeMode = false) { if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo }) if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) - if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index a321b6008..130135394 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -114,7 +114,7 @@ function fixture(fault = '') { if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] }) if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops }) if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] }) - if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2, deliveryIndex: 1, ...(fault === 'missing-measurements' ? {} : { palletSpaces: 1, weightKg: 37, volumeCbm: 0.7, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] }) if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] }) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 7efdfbe4d..5f55d4e43 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -20,7 +20,7 @@ const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo: for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents fill missing slots without replacing existing files from ${initialStage}`, async () => { const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [], replays = new Map() - const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', stage: initialStage, + const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', reviewRound: '1', stage: initialStage, complete: false, editable: true, photos: [{ slot: LOADING_PHOTO_SLOTS[0], fileId: 'existing' }] } let saved = false const context = { async checkpoint() { saved = true }, @@ -28,7 +28,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents async requestMultipart(method, path, file, token) { assert.ok(saved); saved = false; assert.equal(token, 'PDA-TEST'); assert.equal(method, 'POST') const slot = path.split('/').at(-2) - assert.match(path, /idempotencyKey=[a-f0-9]{64}$/); assert.equal(file.type, 'image/png') + assert.match(path, /idempotencyKey=[a-f0-9]{64}&reviewRound=1$/); assert.equal(file.type, 'image/png') assert.equal(file.content.subarray(1, 4).toString(), 'PNG'); calls.push(slot) assert.equal(proof.loadingDocuments.uploads.at(-1).status, 'PREPARED') return ok({ slot, fileId: `new-${slot}` }) @@ -39,6 +39,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents assert.equal(method, 'PUT') if (replays.has(body.idempotencyKey)) return ok(replays.get(body.idempotencyKey)) assert.equal(body.recordVersion, state.recordVersion) + assert.equal(body.uploadKey, new URL(proof.loadingDocuments.uploads.at(-1).path, 'https://unused.test').searchParams.get('idempotencyKey')) state.photos.push({ slot: path.split('/').at(-1), fileId: body.fileId }) state.recordVersion = String(BigInt(state.recordVersion) + 1n) state.complete = state.photos.length === 5 @@ -54,7 +55,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents for (const fault of ['checkpoint', 'upload-unknown', 'wrong-slot', 'replay-drift', 'early-departure']) test(`loading document ${fault} stops before loading confirmation`, async () => { const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [] - const state = { dispatchNo: 'DP-A', stage: 'LOADING', recordVersion: '1', complete: false, editable: true, photos: [] } + const state = { dispatchNo: 'DP-A', stage: 'LOADING', reviewRound: '1', recordVersion: '1', complete: false, editable: true, photos: [] } const context = { async checkpoint() { if (fault === 'checkpoint') throw Error('checkpoint failed') }, assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response?.body?.data }, async requestMultipart(_method, path) { @@ -127,7 +128,7 @@ function server(fault = '') { }, async request(method, path, body, token) { calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' }) - if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && path.endsWith(`/master-data/vehicles/${VID}`)) return ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) if (method === 'GET' && path.includes(`/fleet/drivers/${DRIVER}?`)) return ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } }) if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown') diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 8f2c05570..2fbd99fc1 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -296,9 +296,10 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token) const read = () => command(context, module, 'GET', path, undefined, '回查本车装车资料', token) let documents = await read() const initialStage = documents.stage + const reviewRound = id(documents.reviewRound) const complete = value => LOADING_PHOTO_SLOTS.every(slot => value.photos?.some(photo => photo.slot === slot && id(photo.fileId))) const validate = value => { - if (value.dispatchNo !== proof.dispatchNo || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage) + if (value.dispatchNo !== proof.dispatchNo || !/^[1-9]\d*$/.test(reviewRound) || id(value.reviewRound) !== reviewRound || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage) || value.complete !== complete(value)) throw Error('本车装车资料身份、阶段或五框完整性不匹配') } validate(documents) @@ -307,7 +308,8 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token) if (documents.photos?.some(photo => photo.slot === slot && id(photo.fileId))) continue if (!documents.editable || typeof context.requestMultipart !== 'function') throw Error('缺少本车装车照片写入资格或上传能力') const content = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') - const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${key(proof, `PHOTO_UPLOAD_${slot}`)}`, + const uploadKey = key(proof, `PHOTO_UPLOAD_${slot}`) + const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`, name: `${proof.marker}-TEST-${slot}.png`, sha256: createHash('sha256').update(content).digest('hex'), status: 'PREPARED' } if (proof.loadingDocuments.uploads.some(item => item.slot === slot)) throw Error('该照片上传已开始,保留原键和结果排查,不自动重发') proof.loadingDocuments.uploads.push(upload); await context.checkpoint?.() @@ -319,7 +321,7 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token) await context.checkpoint?.() const photo = context.apiSuccess(module, '上传本轮TEST合成装车照片', response, '', OPTIONS) if (response?.http !== 200 || String(response?.body?.code) !== '200' || photo?.slot !== slot || !id(photo.fileId)) throw Error('装车照片上传未确认,停止绑定及发车') - const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId) } + const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId), uploadKey } const bound = await command(context, module, 'PUT', `${path}/${slot}`, body, '保存本车装车照片', token) validate(bound) check(context, module, '绑定回执保留本槽文件', bound.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId))) diff --git a/scripts/dispatch-loading-flow-test-env.mjs b/scripts/dispatch-loading-flow-test-env.mjs index b2f7e22e5..7ad3f8475 100644 --- a/scripts/dispatch-loading-flow-test-env.mjs +++ b/scripts/dispatch-loading-flow-test-env.mjs @@ -140,10 +140,12 @@ export async function runLoadingFlow(config, env=process.env) { const ledger=async()=>Promise.all(report.members.map(m=>send('GET',`${ROOT}/inventory/ledger?`+new URLSearchParams({customerId:m.customerId,overseasWarehouseId:config.warehouseId,inboundCode:m.incomingNo,page:'1',size:'100'})))) report.stockAfterPick=await ledger() phase('PDA_PHOTOS_AND_ADMIN_FILE_READBACK') - for(const slot of slots){const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${key('UPLOAD-'+slot)}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId)},picker.token)} - const docs=await send('GET',path+'/loading-documents');check('Admin sees five photos',docs.complete && docs.photos.length===5) + const initialDocuments=await send('GET',pp+'/loading-documents',undefined,picker.token),reviewRound=id(initialDocuments.reviewRound),uploadedFiles=new Set() + assert.match(reviewRound,/^[1-9]\d*$/) + for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))} + const docs=await send('GET',path+'/loading-documents');check('Admin sees imported and five added photos',docs.complete && docs.photos.length===initialDocuments.photos.length+5) report.photos=[] - for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && hash(r.bytes)===hash(image));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})} + for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && r.bytes.length>0 && (!uploadedFiles.has(id(photo.fileId)) || hash(r.bytes)===hash(image)));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})} phase('AUTOMATIC_OUTBOUND_AND_DELIVERY') const preview=await send('GET',pp+'/loading',undefined,picker.token),body={recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('LOAD'),remark:'TEST scanned pallets; no manual measurements'} const loaded=await send('POST',pp+'/loading-complete',body,picker.token);check('automatic departure',loaded.stage==='DELIVERING') -- 2.54.0 From 3e0d8d6c88494c7787a3fadf0b41ef968e39fffa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 19:47:18 +0800 Subject: [PATCH 13/70] test(dispatch): verify multi-photo evidence and synchronize PDF storage assertions --- .../service/OwnedBusinessPhotoFileTest.java | 11 ++- scripts/README.md | 2 + scripts/dispatch-loading-flow-test-env.mjs | 86 ++++++++++++++++++- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java index d608a51f3..131d9a58d 100644 --- a/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java @@ -45,8 +45,9 @@ class OwnedBusinessPhotoFileTest { verifyNoInteractions(files, storage); } + /** 装车驾驶证允许 PDF;油耗仍限图片,两类都拒绝空文件、伪造内容和超限文件。 */ @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) - void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception { + void enforcesLoadingAndFuelFormatPoliciesAndRejectsInvalidFiles(String type) throws Exception { var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot(); @@ -60,8 +61,14 @@ class OwnedBusinessPhotoFileTest { for (var upload : new MockMultipartFile[]{ new MockMultipartFile("file", "empty.png", "image/png", new byte[0]), new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()), - new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())}) + new MockMultipartFile("file", "spoof.pdf", "application/pdf", "not-pdf".getBytes())}) assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class); + var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nTEST license".getBytes()); + if (LOADING.equals(type)) { + var saved = storage.store(0, "71", pdf, category); + assertThat(saved.contentType()).isEqualTo("application/pdf"); + assertThat(storage.read(saved.objectKey())).isEqualTo(pdf.getBytes()); + } else assertThatThrownBy(() -> storage.store(0, "71", pdf, category)).isInstanceOf(BizException.class); MultipartFile oversized = mock(MultipartFile.class); when(oversized.isEmpty()).thenReturn(false); when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1); diff --git a/scripts/README.md b/scripts/README.md index 6d8f5b8bb..fe8eef497 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,6 +8,8 @@ Admin 全量可显式传 `--stop-file <本轮唯一停止文件>`。运行前文 `dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及服务端幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据、固定客户、`ADMIN_TEST_OVERSEAS_PDA_ACTORS` 和 `ADMIN_TEST_FLEET_RESOURCES`;不自动创建账号或车辆司机主档。可加 `--stop-after-draft` 准备浏览器验收样本:新建两托及正式预约后停在零已拣草稿,不分配车辆、不送审、不拣货;报告标记 `DRAFT_FIXTURE_ONLY`,不代表完整链路通过,不与 `--resume-report` 混用。报告位于 `reports/dispatch-loading-/`,保留失败前的请求和业务数据;同标识拒绝重跑。`--resume-report <原报告>` 仅接受准备写入前失败或全部准备写入均收到明确拒绝的失败报告,回查原单仍为零拣货草稿后重新准备车辆、三类审核照片与审核,不重复建货或建单;未知或成功写入不能使用此恢复入口。固定PDA账号仅注销会话,不停用;`--keep-pda-for-ui` 在成功后将账号交接信息保存到系统临时目录,页面验收使用私有配置中的既有凭据重新登录,不能将已注销Token当有效会话。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。 +该入口可追加 `--verify-loading-documents --replacement-vehicle-id <独立TEST车辆ID> --replacement-driver-id <独立TEST司机ID>`,在本轮零拣货样本上验证审核自动带入、换车换司机后五类旧资料失效、旧轮次上传/绑定拒绝、平台/PDA混合上传至每类5张、第6张拒绝、满额替换及幂等重放、双端25张内容一致,再继续拣货与自动派送。替换资源必须已维护车牌照片、司机照片与已审核驾驶证,且与初次资源不同;脚本不创建或修改主档,不与草稿模式或旧报告恢复混用。 + Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。 ## TEST 业务数据重置准备 diff --git a/scripts/dispatch-loading-flow-test-env.mjs b/scripts/dispatch-loading-flow-test-env.mjs index 7ad3f8475..17dfb13b4 100644 --- a/scripts/dispatch-loading-flow-test-env.mjs +++ b/scripts/dispatch-loading-flow-test-env.mjs @@ -20,13 +20,16 @@ export function parseFlowArguments(args) { if(args[i]==='--execute')config.execute=true else if(args[i]==='--keep-pda-for-ui')config.keepPda=true else if(args[i]==='--stop-after-draft')config.stopAfterDraft=true + else if(args[i]==='--verify-loading-documents')config.verifyLoadingDocuments=true else { - const key={'--run-id':'runId','--warehouse-id':'warehouseId','--destination-warehouse-id':'destinationId','--test-image':'image','--resume-report':'resumeReport'}[args[i]] + const key={'--run-id':'runId','--warehouse-id':'warehouseId','--destination-warehouse-id':'destinationId','--test-image':'image','--resume-report':'resumeReport','--replacement-vehicle-id':'replacementVehicleId','--replacement-driver-id':'replacementDriverId'}[args[i]] if(!key || config[key] || !args[i+1] || args[i+1].startsWith('--'))throw Error('Unsupported or duplicate argument') config[key]=args[++i] } } if(config.execute && (!/^[A-Za-z0-9_-]{1,48}$/.test(config.runId || '') || !/^[1-9]\d*$/.test(config.warehouseId || '') || !/^[1-9]\d*$/.test(config.destinationId || '') || !config.image))throw Error('Explicit run, warehouses and synthetic image required') + if(config.verifyLoadingDocuments && (config.stopAfterDraft || config.resumeReport || !/^[1-9]\d*$/.test(config.replacementVehicleId || '') || !/^[1-9]\d*$/.test(config.replacementDriverId || '')))throw Error('Loading document verification requires explicit replacement resources and a fresh full flow') + if(!config.verifyLoadingDocuments && (config.replacementVehicleId || config.replacementDriverId))throw Error('Replacement resources require loading document verification') if(config.stopAfterDraft && config.resumeReport)throw Error('Draft UI preparation requires a fresh attempt') return config } @@ -69,6 +72,8 @@ export async function runLoadingFlow(config, env=process.env) { if(r.http!==200 || id(r.body?.code)!=='200')throw Error(`Upload failed ${path}: ${r.body?.message}, requestId=${r.requestId}`) return r.body.data } + // 二进制助手使用当前会话;串行切换后立即恢复,确保实际覆盖 PDA 文件权限。 + const binary=async(path,token=context.token)=>{const previous=context.token;try{context.token=token;return await context.requestBinary('GET',path)}finally{context.token=previous}} const phase=value=>{report.phase=value;save();console.log(JSON.stringify({phase:value,dispatchNo:report.dispatchNo}))} try { const auth=await send('POST','/api/auth/login',{loginId:options.username,password:options.password},'');context.token=auth.accessToken;context.refreshToken=auth.refreshToken @@ -130,9 +135,84 @@ export async function runLoadingFlow(config, env=process.env) { assert((previous.createdResources.fleetTripCommands||[]).every(c=>c.status==='RESPONDED' && c.http===200 && String(c.code)!=='200'),'Only a pre-write failure or definite rejected preparation may resume') report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId) }else report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId) + if(config.verifyLoadingDocuments){ + phase('RESOURCE_PHOTO_ROUNDS_AND_FIVE_PER_CATEGORY') + const read=()=>send('GET',path+'/loading-documents') + const first=await read(), firstRound=id(first.reviewRound) + check('approved trip evidence automatically imported',first.photos.some(p=>p.slot==='LOADING_SCENE') && first.photos.some(p=>p.slot==='LOADING_LIST') && first.photos.every(p=>['LOADING_SCENE','LOADING_LIST'].includes(p.slot))) + const append=async(slot,label,round,platform=false,replaceFileId)=>{ + const base=(platform?path:pp)+'/loading-documents',token=platform?context.token:picker.token,uploadKey=key('UP-'+label) + const photo=await upload(`${base}/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${round}`,`${config.runId}-TEST-${label}.png`,token) + const current=await read(),body={recordVersion:current.recordVersion,idempotencyKey:key('SAVE-'+label),fileId:id(photo.fileId),uploadKey,...(replaceFileId?{replaceFileId}:{})} + return {photo,body,base,token} + } + for(const slot of slots.slice(2)){ + const p=await append(slot,'BEFORE-'+slot,firstRound) + await send('PUT',`${p.base}/${slot}`,p.body,p.token) + } + const pending=await append('PRE_DEPARTURE_CHECK','OLD-PENDING',firstRound) + const beforeChange=await read(),oldFiles=new Set(beforeChange.photos.map(p=>id(p.fileId))) + let detail=await send('GET',path) + check('replacement resources differ from original',id(detail.vehicleInfo.vehicle.vehicleId)!==config.replacementVehicleId && id(detail.vehicleInfo.driver.driverId)!==config.replacementDriverId) + const vehicleArchive=await send('GET',`${ROOT}/fleet/resource-photos/VEHICLE/${config.replacementVehicleId}`) + const driverArchive=await send('GET',`${ROOT}/fleet/drivers/${config.replacementDriverId}`) + const driverPortrait=await send('GET',`${ROOT}/fleet/resource-photos/DRIVER/${config.replacementDriverId}`) + assert(vehicleArchive.photo?.fileId && driverArchive.licenseFileId && driverPortrait.photo?.fileId,'Replacement resources require maintained vehicle photo, approved driver license and driver portrait') + await send('POST',path+'/return-to-review',{recordVersion:detail.recordVersion,idempotencyKey:key('RETURN'),reason:'TEST zero-pick resource evidence invalidation'}) + detail=await send('GET',path) + let result=await send('PUT',path,{recordVersion:detail.recordVersion,idempotencyKey:key('SWAP'),maintenance:detail.vehicleInfo.maintenance, + vehicle:{mode:'MASTER',vehicleId:config.replacementVehicleId},driver:{mode:'MASTER',driverId:config.replacementDriverId,evidence:{expectedTaskEndAt:report.trip.expectedDriverEndAt}}, + plannedDepartureAt:report.trip.updateRequest.plannedDepartureAt,estimatedArrivalAt:report.trip.updateRequest.estimatedArrivalAt,remark:'TEST replacement with maintained archives'}) + for(const action of ['submit-review','dispatch-review','loading-review'])result=await send('POST',`${path}/${action}`,{recordVersion:result.recordVersion,idempotencyKey:key('SECOND-'+action),...(action==='submit-review'?{remark:'TEST second review'}:{decision:'APPROVED',reason:'TEST second review'})}) + const second=await read(),round=id(second.reviewRound) + check('new review round invalidates all old resource evidence',Number(round)>Number(firstRound) && second.photos.length===2 && second.photos.every(p=>!oldFiles.has(id(p.fileId))) && ['LOADING_SCENE','LOADING_LIST'].every(s=>second.photos.some(p=>p.slot===s))) + const sourcePaths={LOADING_SCENE:`${ROOT}/fleet/drivers/${config.replacementDriverId}/license/${driverArchive.licenseFileId}`,LOADING_LIST:`${ROOT}/fleet/resource-photos/VEHICLE/${config.replacementVehicleId}/${vehicleArchive.photo.fileId}/content`} + report.importedArchives=[] + for(const photo of second.photos){ + const original=await context.requestBinary('GET',sourcePaths[photo.slot]) + const copied=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`) + const pdaCopy=await binary(`${pp}/loading-documents/${photo.slot}/files/${photo.fileId}/content`,picker.token) + report.importedArchives.push({slot:photo.slot,fileId:photo.fileId,contentType:copied.contentType,sourceHttp:original.http,copyHttp:copied.http,pdaHttp:pdaCopy.http,sha256:hash(copied.bytes),requestId:copied.requestId,traceId:copied.traceId}) + check('maintained archive copied exactly '+photo.slot,original.http===200 && copied.http===200 && pdaCopy.http===200 && original.bytes.length>0 && hash(original.bytes)===hash(copied.bytes) && hash(original.bytes)===hash(pdaCopy.bytes)) + } + await send('PUT',`${pending.base}/PRE_DEPARTURE_CHECK`,{...pending.body,recordVersion:second.recordVersion},pending.token,true) + check('stale upload bind preserves current photos',JSON.stringify(await read())===JSON.stringify(second)) + const stale=await context.requestMultipart('POST',`${pp}/loading-documents/PRE_DEPARTURE_CHECK/uploads?idempotencyKey=${key('STALE-ROUND')}&reviewRound=${firstRound}`,{name:'TEST-stale-round.png',type:'image/png',content:image},picker.token) + check('stale review round upload rejected',id(stale.body?.code)==='A0409') + for(const [slotIndex,slot] of slots.entries()){ + let docs=await read(),count=docs.photos.filter(p=>p.slot===slot).length + for(let index=count;index<5;index++){ + const p=await append(slot,`${slot}-${index}`,round,(index+slotIndex)%2===0) + await send('PUT',`${p.base}/${slot}`,p.body,p.token) + } + docs=await read();check('five saved '+slot,docs.photos.filter(p=>p.slot===slot).length===5) + const excess=await append(slot,'EXCESS-'+slot,round) + const rejected=await context.request('PUT',`${excess.base}/${slot}`,excess.body,excess.token,{}) + check('sixth rejected '+slot,['A0400','A0409'].includes(id(rejected.body?.code))) + check('sixth rejection preserves version '+slot,JSON.stringify(await read())===JSON.stringify(docs)) + const replace=await append(slot,'REPLACE-'+slot,round,true,id(docs.photos.find(p=>p.slot===slot).fileId)) + const replaced=await send('PUT',`${replace.base}/${slot}`,replace.body,replace.token) + const replay=await send('PUT',`${replace.base}/${slot}`,replace.body,replace.token) + check('full category replacement and replay '+slot,replaced.photos.filter(p=>p.slot===slot).length===5 && replaced.photos.some(p=>id(p.fileId)===id(replace.photo.fileId)) && !replaced.photos.some(p=>id(p.fileId)===replace.body.replaceFileId) && JSON.stringify(replay)===JSON.stringify(replaced)) + } + const adminDocs=await read(),pdaDocs=await send('GET',pp+'/loading-documents',undefined,picker.token) + check('Admin and PDA share all 25 photos',adminDocs.photos.length===25 && adminDocs.complete && JSON.stringify(adminDocs)===JSON.stringify(pdaDocs)) + report.sharedFiles=[] + for(const photo of adminDocs.photos){ + const admin=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`) + const pda=await binary(`${pp}/loading-documents/${photo.slot}/files/${photo.fileId}/content`,picker.token) + report.sharedFiles.push({slot:photo.slot,fileId:photo.fileId,adminHttp:admin.http,pdaHttp:pda.http,adminSha256:hash(admin.bytes),pdaSha256:hash(pda.bytes),requestId:pda.requestId,traceId:pda.traceId}) + check('Admin and PDA file bytes '+photo.slot+' '+photo.fileId,admin.http===200 && pda.http===200 && hash(admin.bytes)===hash(image) && hash(pda.bytes)===hash(image)) + } + report.loadingDocumentVerification={reviewRound:round,photos:25,replacementVehicleId:config.replacementVehicleId,replacementDriverId:config.replacementDriverId} + } for(const [index,m] of report.members.entries()) { const task=await send('GET',pp,undefined,picker.token),item=task.items.find(i=>i.palletNo===m.palletNo);assert(item);m.dispatchItemId=id(item.dispatchItemId) const body={recordVersion:task.recordVersion,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key('PICK-'+index),remark:context.marker} + // 新实收库存仍须走当前仓租预校验,不能用旧脚本绕过正式扫码前置。 + let preparation=await send('POST',PDA+'/storage-rent/preparations',{mode:'DISPATCH',businessNo:report.dispatchNo,itemId:m.dispatchItemId,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:body.idempotencyKey,cartonMarks:[]},picker.token) + for(let attempt=0;!preparation.ready && preparation.status==='FREEZE_PENDING' && attempt<20;attempt++){await new Promise(resolve=>setTimeout(resolve,500));preparation=await send('GET',PDA+'/storage-rent/preparations/'+preparation.preparationId,undefined,picker.token)} + check('storage rent ready before pick '+index,preparation.ready===true) const picked=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token) const replay=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token);check('pick replay '+index,replay.replayed===true && id(replay.recordVersion)===id(picked.recordVersion)) if(index===0){const preview=await send('GET',pp+'/loading',undefined,picker.token);await send('POST',pp+'/loading-complete',{recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('INCOMPLETE')},picker.token,true)} @@ -142,8 +222,8 @@ export async function runLoadingFlow(config, env=process.env) { phase('PDA_PHOTOS_AND_ADMIN_FILE_READBACK') const initialDocuments=await send('GET',pp+'/loading-documents',undefined,picker.token),reviewRound=id(initialDocuments.reviewRound),uploadedFiles=new Set() assert.match(reviewRound,/^[1-9]\d*$/) - for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))} - const docs=await send('GET',path+'/loading-documents');check('Admin sees imported and five added photos',docs.complete && docs.photos.length===initialDocuments.photos.length+5) + if(!config.verifyLoadingDocuments)for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))} + const docs=await send('GET',path+'/loading-documents');check('Admin sees all saved photos',docs.complete && docs.photos.length===initialDocuments.photos.length+(config.verifyLoadingDocuments?0:5)) report.photos=[] for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && r.bytes.length>0 && (!uploadedFiles.has(id(photo.fileId)) || hash(r.bytes)===hash(image)));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})} phase('AUTOMATIC_OUTBOUND_AND_DELIVERY') -- 2.54.0 From 00616d5930018df952c7947ba8e581de39aa01a9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 19:58:07 +0800 Subject: [PATCH 14/70] fix(lastleg): resolve transfer storage rent by inbound association --- .../entity/StorageRentBusinessSource.java | 1 + .../mapper/StorageRentBusinessSourceMapper.java | 4 ++-- .../service/StorageRentSourceResolver.java | 6 ++++++ .../service/StorageRentSourceResolverTest.java | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java index 31489ffda..542ec4d20 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/entity/StorageRentBusinessSource.java @@ -8,4 +8,5 @@ public class StorageRentBusinessSource { private Long id; private String businessNo; private Long lastlegOrderId; + private Long inboundOrderId; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java index de311aa2f..728005d24 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java @@ -13,9 +13,9 @@ public interface StorageRentBusinessSourceMapper { @Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND relabel_no=#{number} LIMIT 1") StorageRentBusinessSource relabelNo(@Param("customer") String customer,@Param("number") String number); /** 仓储转运独立业务单归属。 */ - @Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} LIMIT 1") + @Select("SELECT id,transfer_no AS business_no,lastleg_order_id,inbound_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} LIMIT 1") StorageRentBusinessSource transfer(@Param("customer") String customer,@Param("number") String number); /** 原尾程投影映射唯一仓储转运业务。 */ - @Select("SELECT id,transfer_no AS business_no,lastleg_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND lastleg_order_id=#{id} LIMIT 1") + @Select("SELECT id,transfer_no AS business_no,lastleg_order_id,inbound_order_id FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND lastleg_order_id=#{id} LIMIT 1") StorageRentBusinessSource transferByTail(@Param("customer") String customer,@Param("id") Long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java index 6bdff18e1..410548e70 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolver.java @@ -78,6 +78,12 @@ public class StorageRentSourceResolver { owner=new Owner(customer,null,"RETURN_ORDER",order.getId(),order.getReturnNo(),order.getLastlegOrderId(),null,warehouse); }else owner=business(customer,null,"LASTLEG_ORDER",null,item.getSourceOrderId(),warehouse,item.getInboundCode()); }else throw conflict("整托预校验应先展开为真实扫码来源"); + // 转运的尾程单与入库单独立建号;库存归属必须核对业务单保存的真实入库关联。 + if("WAREHOUSE_TRANSFER".equals(owner.businessType())){ + var transfer=repository.transfer(customer,owner.businessNo()); + if(transfer==null||transfer.getInboundOrderId()==null)throw conflict("转运业务单缺少原入库关联,不能预占仓租"); + expectedOrderId=transfer.getInboundOrderId(); + } List allocations; if(marks!=null){if(request.positionId()!=null)throw conflict("退货扫码不能指定普通库存位置");allocations=returnAllocations(customer,warehouse,marks,sku,pieceQuantity,at);} else { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java index dd0dd5d66..0af046425 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentSourceResolverTest.java @@ -56,6 +56,21 @@ class StorageRentSourceResolverTest { assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4");assertThat(plan.amount()).isEqualByComparingTo("0.96"); } + /** 转运尾程单与入库单不同号仍能预校验;错入库关联和缺失关联必须拒绝。 */ + @Test void transferUsesItsInboundAssociationInsteadOfTailOrderIdentity(){ + item.setOrderId(171L);item.setSourceBusinessType("WAREHOUSE_TRANSFER");item.setSourceBusinessNo("ZY-1"); + var source=new StorageRentBusinessSource();source.setId(81L);source.setBusinessNo("ZY-1"); + source.setLastlegOrderId(171L);source.setInboundOrderId(91L); + when(repository.transfer("101","ZY-1")).thenReturn(source); + var plan=service.resolve("9",request,at); + assertThat(plan.owner().businessNo()).isEqualTo("ZY-1");assertThat(plan.owner().orderId()).isEqualTo(171L); + assertThat(plan.allocations()).hasSize(1);assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4"); + source.setInboundOrderId(92L); + assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("业务单据关联的入库订单"); + source.setInboundOrderId(null); + assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("缺少原入库关联"); + } + /** 当前客户覆盖默认价;同一预占消费保存价,余货下次预占读新价且库龄不变。 */ @Test void currentCustomerPricingAndSavedSnapshotKeepOriginalAge(){ var first=customPricing("USD",1,"2","5");when(pricing.current("101")).thenReturn(first); -- 2.54.0 From 83f2fda4cd2a432c7516018f6ce3b1b05eb1d2d7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 27 Oct 2026 12:08:35 +0800 Subject: [PATCH 15/70] fix(finance): reconcile payment progress audit amounts and customer names --- .../finance/dto/BusinessFeeSourceVersion.java | 4 + .../mapper/BusinessFeeSourceMapper.java | 27 ++ .../mapper/FinanceFeeSourceReadMapper.java | 20 +- .../BusinessFeeSourceRepository.java | 20 ++ .../FinanceFeeSourceReadRepository.java | 16 ++ .../BusinessFeeSourceEventService.java | 53 +++- .../finance/service/FinanceCustomerNames.java | 71 ++++++ .../service/FinanceExportProviderService.java | 55 ++-- .../FinancePhase1OperationService.java | 29 +-- .../finance/service/FinanceQueryService.java | 13 +- .../service/FinanceSettlementService.java | 18 +- .../service/ReceivableStatisticsService.java | 7 +- .../ReconciliationStatementService.java | 23 +- .../BusinessFeeSourceEventServiceTest.java | 91 ++++++- ...nessFeeSourceReceptionPersistenceTest.java | 238 ++++++++++++++++++ .../CustomerAccountSummaryBehaviorTest.java | 8 +- .../finance/FinanceCustomerNamesTest.java | 53 ++++ .../FinanceCustomerNamesTestSupport.java | 22 ++ .../FinanceDirectPaidCostReadTest.java | 2 +- .../FinanceFeePoolDirectionBehaviorTest.java | 6 +- .../FinanceFeeSourceReadMapperTest.java | 50 +++- .../FinancePhase1OperationBehaviorTest.java | 81 +++--- ...ancePlatformOptionalCustomerQueryTest.java | 6 +- .../FinanceSettlementBehaviorTest.java | 3 +- .../ReceivableStatisticsServiceTest.java | 27 +- .../ReconciliationStatementBehaviorTest.java | 33 ++- .../FinanceExportProviderServiceTest.java | 20 ++ 27 files changed, 820 insertions(+), 176 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java create mode 100644 finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java create mode 100644 finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java create mode 100644 finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java diff --git a/finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java b/finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java new file mode 100644 index 000000000..c4d34898b --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/BusinessFeeSourceVersion.java @@ -0,0 +1,4 @@ +package com.baiyue.finance.dto; + +/** 接收事务锁定的来源版本及其不可变客户归属。 */ +public record BusinessFeeSourceVersion(int sourceVersion, long customerId) { } diff --git a/finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java new file mode 100644 index 000000000..60407b524 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/BusinessFeeSourceMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.finance.mapper; + +import com.baiyue.finance.dto.BusinessFeeSourceVersion; +import org.apache.ibatis.annotations.Arg; +import org.apache.ibatis.annotations.ConstructorArgs; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 费用接收时锁定稳定来源,防止新版本改写其他客户的费用。 */ +@Mapper +public interface BusinessFeeSourceMapper { + /** 使用完整来源键锁定全部版本;锁定读取必须绕过缓存并参与接收事务。 */ + @Select("SELECT source_fee_version,customer_id FROM fin_fee_source " + + "WHERE source_system=#{system} AND source_type=#{type} AND source_fee_id=#{feeId} " + + "ORDER BY source_fee_version FOR UPDATE") + @ConstructorArgs({ + @Arg(column = "source_fee_version", javaType = int.class), + @Arg(column = "customer_id", javaType = long.class) + }) + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List lockVersions(@Param("system") String system, + @Param("type") String type, @Param("feeId") long feeId); +} diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java index 4fa153600..bc415481b 100644 --- a/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java @@ -3,14 +3,23 @@ package com.baiyue.finance.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Options; import java.util.List; import java.util.Map; /** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */ @Mapper public interface FinanceFeeSourceReadMapper { - String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))"; - String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END"; + String GENERATED = "(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 bl.source_version=f.source_fee_version AND b.status<>'VOIDED'))"; + // 应付进度取同客户的有效结算单;作废单释放来源,直接已付使用来源中的付款事实。 + String PAYABLE_STATUS = "(SELECT CASE s.payment_status WHEN 'PAID' THEN 'PAID' WHEN 'PARTIALLY_PAID' THEN 'PARTIALLY_PAID'" + + " ELSE 'PAYABLE_BILLED' END FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id" + + " WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED' ORDER BY s.id DESC LIMIT 1)"; + String STATUS = "CASE WHEN f.status IN ('VOIDED','SUPERSEDED','SETTLED') THEN f.status" + + " WHEN f.direction='PAYABLE' THEN COALESCE(" + PAYABLE_STATUS + ",CASE WHEN f.settlement_mode='DIRECT_PAID'" + + " AND f.review_status='APPROVED' AND f.paid_at IS NOT NULL THEN 'PAID' ELSE f.status END)" + + " WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END"; String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)"; String FILTER = " FROM fin_fee_source f WHERE 1=1 " + "" + @@ -41,6 +50,13 @@ public interface FinanceFeeSourceReadMapper { " ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}") List> list(Map filters); + /** 详情与列表共用状态投影,仍允许通过精确 ID 查看历史版本。 */ + @Select("") + // 同事务的审核/调整仍含 JDBC 写入,读取必须清空 MyBatis 会话缓存才能返回变更后状态。 + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + Map detail(@Param("id") long id); + String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" + " AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" + " AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" + diff --git a/finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java b/finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java new file mode 100644 index 000000000..4c08e3b38 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/BusinessFeeSourceRepository.java @@ -0,0 +1,20 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.dto.BusinessFeeSourceVersion; +import com.baiyue.finance.mapper.BusinessFeeSourceMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** 为接收服务提供同一事务中的费用来源版本与归属事实。 */ +@Repository +@RequiredArgsConstructor +public class BusinessFeeSourceRepository { + private final BusinessFeeSourceMapper mapper; + + /** 原来源的客户不能被新版本替换,查询范围不得按传入客户提前过滤。 */ + public List lockVersions(String system, String type, long feeId) { + return mapper.lockVersions(system, type, feeId); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java index c5862944e..5dde48e22 100644 --- a/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java +++ b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java @@ -1,6 +1,7 @@ package com.baiyue.finance.repository; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog; import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper; import com.baiyue.finance.service.FinanceEventException; @@ -51,6 +52,21 @@ public class FinanceFeeSourceReadRepository { return mapper.list(filters).stream().map(this::summary).toList(); } + /** 精确版本详情使用与列表相同的金额和状态,并保留审核信息及操作记录。 */ + public FeeSourceDetail detail(long id) { + Map raw = mapper.detail(id); + if (raw == null || raw.isEmpty()) throw new FinanceEventException("FIN-FEE-404", "费用来源不存在"); + Map row = row(raw); + FeeSourceSummary s = summary(row); + return new FeeSourceDetail(s.feeSourceId(), s.sourceSystem(), s.sourceType(), s.sourceBusinessId(), s.sourceFeeId(), + s.sourceVersion(), s.direction(), s.customerId(), s.customerName(), s.supplierId(), s.supplierName(), s.orderId(), + s.inboundId(), s.inboundCode(), s.containerId(), s.containerNo(), s.businessDate(), s.feeItemName(), + s.quantity(), s.quantityUnit(), s.unitPrice(), s.periodStart(), s.periodEnd(), s.amount(), s.currency(), + s.originalCurrency(), s.originalAmount(), s.exchangeRate(), s.exchangeRateMonth(), s.reviewStatus(), + s.sourceStatus(), s.statementGenerated(), text(row.get("review_reason")), text(row.get("source_hash")), + ((Number) row.get("operation_version")).intValue(), logs(s.customerId(), id)); + } + /** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */ public List logs(long customerId, long id) { List result = new ArrayList<>(); diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 9796e4820..20c27e713 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -6,6 +6,7 @@ import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; +import com.baiyue.finance.repository.BusinessFeeSourceRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -13,11 +14,13 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.List; import java.util.Map; import java.util.Set; -/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */ +/** 接收海外费用不可变快照,在同一事务内完成来源版本与接收记录。 */ @Service @RequiredArgsConstructor public class BusinessFeeSourceEventService { @@ -26,7 +29,9 @@ public class BusinessFeeSourceEventService { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceSupplierValidator supplierValidator; + private final BusinessFeeSourceRepository sources; + /** 先核对不可变事件,再恢复已提交结果;仅新事件检查外部主档。 */ @Transactional public DeliveryAck consume(EventEnvelope envelope) { try { @@ -56,6 +61,8 @@ public class BusinessFeeSourceEventService { return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); } + // 回执丢失后的原事件重放不能依赖供应商当前是否可用,否则已入账事件无法确认。 + validateSupplier(source); jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')", envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash()); long result = apply(sourceSystem, source); @@ -64,6 +71,7 @@ public class BusinessFeeSourceEventService { return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } + /** 锁定同一来源的历史版本,拒绝跨客户替代,并保留已出账来源冻结规则。 */ private long apply(String system, BusinessFeeSourceChangedV1 source) { long feeId = positive(source.sourceFeeId(), "sourceFeeId"); List> same = jdbc.queryForList( @@ -83,10 +91,12 @@ public class BusinessFeeSourceEventService { } if ("VOIDED".equals(source.action())) throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); - List> versions = jdbc.queryForList( - "SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE", - system, source.sourceType(), feeId); - if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion())) + var versions = sources.lockVersions(system, source.sourceType(), feeId); + long customerId = positive(source.customerId(), "customerId"); + // 来源身份跨版本保持客户归属;检查失败时 Inbox 插入也随当前事务回滚。 + if (versions.stream().anyMatch(row -> row.customerId() != customerId)) + throw error("FIN-EVT-018", "费用来源客户归属不允许变更"); + if (versions.stream().anyMatch(row -> row.sourceVersion() > source.sourceVersion())) throw error("FIN-EVT-008", "费用来源版本已过期"); requireSourceNotFrozen(system, source.sourceType(), feeId); jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'", @@ -110,6 +120,7 @@ public class BusinessFeeSourceEventService { return id; } + /** 校验载荷自身的身份、金额和摘要,不依赖会随时间变化的外部数据。 */ private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) { if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem()) || "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType()) @@ -130,6 +141,7 @@ public class BusinessFeeSourceEventService { } positive(value.sourceFeeId(), "sourceFeeId"); positive(value.customerId(), "customerId"); + validateAmounts(value); if (value.walletSettlement() != null) { if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用"); positive(value.walletSettlement().holdId(), "walletSettlement.holdId"); @@ -149,10 +161,8 @@ public class BusinessFeeSourceEventService { throw error("FIN-EVT-015", "费用来源尚未发布或已失效"); } if ("PAYABLE".equals(value.direction())) { - long supplierId = positive(value.supplierId(), "supplierId"); - var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory())); - if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name())) - throw error("FIN-EVT-012", "应付来源供应商快照不一致"); + positive(value.supplierId(), "supplierId"); + if (blank(value.supplierName())) throw error("FIN-EVT-012", "应付来源供应商快照不完整"); validateSettlement(value); } else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory()) || value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null @@ -174,6 +184,31 @@ public class BusinessFeeSourceEventService { if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); } + /** 使用来源冻结汇率复核人民币金额,与海外生产端的分位四舍五入口径一致。 */ + private void validateAmounts(BusinessFeeSourceChangedV1 value) { + try { + // 不在接收端静默修正金额或截断汇率;无效精度须由生产端重新审核。 + value.originalAmount().setScale(2, RoundingMode.UNNECESSARY); + value.totalAmount().setScale(2, RoundingMode.UNNECESSARY); + value.exchangeRate().setScale(6, RoundingMode.UNNECESSARY); + } catch (ArithmeticException invalidPrecision) { + throw error("FIN-EVT-019", "费用金额或汇率精度无效"); + } + if ("CNY".equals(value.originalCurrency()) && value.exchangeRate().compareTo(BigDecimal.ONE) != 0) + throw error("FIN-EVT-019", "人民币原币费用汇率必须为1"); + BigDecimal expected = value.originalAmount().multiply(value.exchangeRate()).setScale(2, RoundingMode.HALF_UP); + if (expected.compareTo(value.totalAmount()) != 0) + throw error("FIN-EVT-019", "费用人民币金额与原币金额、汇率不一致"); + } + + /** 首次接收应付仍须校验启用供应商与名称快照,依赖不可用时失败关闭。 */ + private void validateSupplier(BusinessFeeSourceChangedV1 value) { + if (!"PAYABLE".equals(value.direction())) return; + var supplier = supplierValidator.requireActive(positive(value.supplierId(), "supplierId"), trim(value.supplierCategory())); + if (!value.supplierName().equals(supplier.name())) + throw error("FIN-EVT-012", "应付来源供应商快照不一致"); + } + private void validateSettlement(BusinessFeeSourceChangedV1 value) { boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); if ("ON_ACCOUNT".equals(value.settlementMode())) { diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java new file mode 100644 index 000000000..d802e61f9 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerNames.java @@ -0,0 +1,71 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.IamCustomerClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.function.BiFunction; + +/** 单据客户名称取真实主档或有效快照,内部引用不能作为名称展示。 */ +@Service +public class FinanceCustomerNames { + private final IamCustomerClient customers; + private final String token; + + /** 复用已存在的 IAM 客户查询,不跨库读取客户资料。 */ + public FinanceCustomerNames(IamCustomerClient customers, + @Value("${baiyue.security.internal-service-token}") String token) { + this.customers = customers; + this.token = token; + } + + /** 新建单据必须取得真实客户名称,依赖故障或客户不存在时停止建单。 */ + public String required(long customerId) { + String name = lookup(customerId); + if (name == null) throw new FinanceEventException("FIN-CUSTOMER-404", "客户名称未提供,无法生成财务单据"); + return name; + } + + /** 应收结算沿用有效的对账单名称,缺失时必须取得真实主档后才能建单。 */ + public String requiredSnapshot(long customerId, String snapshot) { + return valid(customerId, snapshot) ? snapshot.trim() : required(customerId); + } + + /** 每次列表/详情请求内复用同客户查询结果,保留有效的历史名称快照。 */ + public BiFunction resolver() { + var names = new HashMap(); + return (customerId, snapshot) -> { + if (valid(customerId, snapshot)) return snapshot.trim(); + return names.computeIfAbsent(customerId, id -> { + String name = lookup(id); + return name == null ? "—" : name; + }); + }; + } + + /** 仅接受所请求客户的主档,不能拿其他客户记录填充当前单据。 */ + private String lookup(long customerId) { + try { + var response = customers.page(token, 1, 1, null, null, customerId); + if (response == null || !response.successful() || response.data() == null || response.data().records() == null) + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + var rows = response.data().records(); + if (rows.isEmpty()) return null; + if (rows.size() != 1 || rows.get(0).id() != customerId) + throw new FinanceEventException("FIN_503", "客户主数据返回范围异常"); + String name = rows.get(0).tenantName(); + return valid(customerId, name) ? name.trim() : null; + } catch (FinanceEventException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + } + + /** 空名称与内部编号占位不是有效的业务名称。 */ + private static boolean valid(long customerId, String name) { + return name != null && !name.isBlank() && !name.trim().equals("—") + && !name.trim().equals(Long.toString(customerId)) && !name.trim().matches("客户\\s*(?:ID)?\\s*\\d+"); + } +} 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 ce9af6f84..020b496f1 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -7,13 +7,12 @@ import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; import com.baiyue.common.core.export.ExportContracts.ExportColumn; import com.baiyue.common.core.export.ExportContracts.ExportColumnType; import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; -import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail; 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.FinancePhase1Dtos.FeeSourceSummary; import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -199,41 +198,16 @@ public class FinanceExportProviderService { .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 generated = bool(p, "statementGenerated"); - LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo"); - String keyword = lower(text(p, "keyword")); List> rows = new ArrayList<>(); - for (FeePoolItem item : queryService.feePool(customerId, direction)) { - FeeSourceDetail detail = detail(item); - String sourceStatus = detail == null ? item.status() : detail.sourceStatus(); - String actualReview = detail == null ? null : detail.reviewStatus(); - 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 (generated != null && !generated.equals(actualGenerated)) continue; - if (from != null && (businessDate == null || businessDate.isBefore(from))) continue; - if (to != null && (businessDate == null || businessDate.isAfter(to))) continue; - String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()), - detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo()))); - if (keyword != null && !searchable.contains(keyword)) continue; - rows.add(feeRow(item, detail, businessDate)); + for (int page = 1; ; page++) { + var result = operationService.listFinanceFeeSources(page, 200, longValue(p, "customerId", false), + text(p, "direction"), text(p, "status"), text(p, "reviewStatus"), bool(p, "statementGenerated"), + date(p, "businessDateFrom"), date(p, "businessDateTo"), text(p, "keyword")); + result.data().forEach(source -> rows.add(feeRow(source))); + if ((long) page * 200 >= result.page().total()) return rows; } - return rows; - } - - private FeeSourceDetail detail(FeePoolItem item) { - if ("MANUAL_COST".equals(item.sourceType())) return null; - String[] parts = item.poolItemId().split(":", -1); - if (parts.length != 2) return null; - try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); } - catch (RuntimeException ignored) { return null; } } private List> statementRows(Map p) { @@ -376,13 +350,12 @@ public class FinanceExportProviderService { "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(), "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); } - private Map feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) { - return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(), - "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(), - "sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated()); + private Map feeRow(FeeSourceSummary source) { + return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(), + "sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(), + "containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(), + "quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(), + "reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated()); } private Map statementRow(ReconciliationDetail value) { return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), 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 856ae61d7..81bfd5be0 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -178,7 +178,7 @@ public class FinancePhase1OperationService { String normalizedReview = normalized(reviewStatus, List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); String normalizedSourceStatus = normalized(sourceStatus, - List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); + List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "PAYABLE_BILLED", "PARTIALLY_PAID", "PAID", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); Map filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus, normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize); return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters)); @@ -186,9 +186,7 @@ public class FinancePhase1OperationService { public FeeSourceDetail getFinanceFeeSource(long feeSourceId) { if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效"); - List rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId); - if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在"); - return rows.get(0); + return feeSources.detail(feeSourceId); } @Transactional @@ -225,6 +223,7 @@ public class FinancePhase1OperationService { adjustment ? "POST" : "PUT", path, request); if (replay != null) return getFinanceFeeSource(replay); if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整"); + if (!"AVAILABLE".equals(before.sourceStatus())) throw error("FIN-FEE-409", "费用已被占用、结清或作废,不可编辑或调整"); String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus(); if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) { throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑"); @@ -302,6 +301,7 @@ public class FinancePhase1OperationService { "/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 (!"AVAILABLE".equals(before.sourceStatus())) 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=?", @@ -315,27 +315,6 @@ public class FinancePhase1OperationService { return after; } - /** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */ - private FeeSourceDetail detail(ResultSet rs) throws SQLException { - JsonNode snapshot = parse(rs.getString("source_snapshot")); - long id = rs.getLong("id"); - boolean generated = Boolean.TRUE.equals(jdbc.queryForObject("SELECT COUNT(*)>0 FROM fin_bill_line WHERE source_id=? AND source_version=?", Boolean.class, id, rs.getInt("source_fee_version"))); - String status = rs.getString("status"); - if (generated && "AVAILABLE".equals(status)) status = "BILLED"; - BigDecimal quantity = decimal(snapshot, "chargeableQuantity"); - BigDecimal amount = rs.getBigDecimal("amount"); - BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount); - BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), - quantity != null && quantity.signum() > 0 ? originalAmount.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"), 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, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"), - amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, - firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), - defaultString(rs.getString("review_status"), "DRAFT"), status, generated, - rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id)); - } - private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) { if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key"); String hash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body)); 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 9d41b2eb9..435c89856 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceQueryService.java @@ -40,10 +40,12 @@ public class FinanceQueryService { private static final String CNY = "CNY"; private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; + private final FinanceCustomerNames customerNames; - public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) { + public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceCustomerNames customerNames) { this.jdbc = jdbc; this.objectMapper = objectMapper; + this.customerNames = customerNames; } public CustomerAccount customerAccount(long customerId) { @@ -131,12 +133,13 @@ public class FinanceQueryService { public List statements(Long customerId, boolean clientVisibleOnly) { validateOptionalCustomer(customerId); + var names = customerNames.resolver(); 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,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, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), 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"), @@ -147,6 +150,7 @@ public class FinanceQueryService { public List reconciliationStatements(Long customerId) { validateOptionalCustomer(customerId); + var names = customerNames.resolver(); 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.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," + @@ -163,7 +167,7 @@ public class FinanceQueryService { Object[] args = new Object[0]; if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; } return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary( - rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), + rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"), 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"), @@ -178,9 +182,10 @@ public class FinanceQueryService { public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) { requireCustomer(customerId); + var names = customerNames.resolver(); 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,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, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), names.apply(rs.getLong("tenant_id"), 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"), 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 3f1e8a1fa..4259966d4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -52,19 +52,21 @@ public class FinanceSettlementService { private final FinanceVoucherValidator voucherValidator; private final FinanceSupplierValidator supplierValidator; private final FinanceDocumentNumberGenerator documentNumbers; + private final FinanceCustomerNames customerNames; /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, FinanceSupplierValidator supplierValidator, FinanceDocumentNumberGenerator documentNumbers, - com.baiyue.finance.repository.FinanceSettlementDetailRepository details) { + com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames) { this.jdbc = jdbc; this.details = details; this.objectMapper = objectMapper; this.voucherValidator = voucherValidator; this.supplierValidator = supplierValidator; this.documentNumbers = documentNumbers; + this.customerNames = customerNames; } /** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */ @@ -98,7 +100,7 @@ public class FinanceSettlementService { LocalDate generatedDate = LocalDate.now(); long settlementId = insertSettlement(customerId, documentNumbers.next("AR"), "RECEIVABLE", - text(bill, "customer_name_snapshot"), null, null, request.reconciliationStatementId(), + customerNames.requiredSnapshot(customerId, 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); int inserted = jdbc.update( @@ -155,8 +157,7 @@ public class FinanceSettlementService { if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); String supplierCategory = supplierCategoryConstraint(sources); String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategory).name(); - String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName")) - .filter(StringUtils::hasText).findFirst().orElse("客户" + customerId); + String customerName = customerNames.required(customerId); LocalDate generatedDate = LocalDate.now(); long settlementId = insertSettlement(customerId, documentNumbers.next("AP"), "PAYABLE", customerName, request.supplierId(), supplierName, null, generatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM")), @@ -176,7 +177,7 @@ public class FinanceSettlementService { if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); List> records = details.summary(settlementId); if (records.isEmpty()) throw error("FIN_404", "结算单不存在"); - SettlementSummary summary = summary(records.get(0)); + SettlementSummary summary = summary(records.get(0), customerNames.resolver()); List lines = details.lines(summary.customerId(), settlementId) .stream().map(source -> line(source, summary)).toList(); List transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId) @@ -206,7 +207,8 @@ public class FinanceSettlementService { args.add(customerId); } sql += " ORDER BY s.generated_date DESC,s.id DESC"; - return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList(); + var names = customerNames.resolver(); + return jdbc.queryForList(sql, args.toArray()).stream().map(row -> summary(row, names)).toList(); } @Transactional @@ -419,9 +421,9 @@ public class FinanceSettlementService { MDC.get("requestId"), MDC.get("traceId")); } - private SettlementSummary summary(Map row) { + private SettlementSummary summary(Map row, java.util.function.BiFunction names) { return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"), - text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"), + text(row, "direction"), number(row, "tenant_id").longValue(), names.apply(number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot")), nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"), nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"), text(row, "business_period"), text(row, "fee_item_summary"), diff --git a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java index 07ce5c305..2d1940454 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java @@ -15,9 +15,11 @@ import java.util.List; public class ReceivableStatisticsService { private static final String CURRENCY = "CNY"; private final JdbcTemplate jdbc; + private final FinanceCustomerNames customerNames; - public ReceivableStatisticsService(JdbcTemplate jdbc) { + public ReceivableStatisticsService(JdbcTemplate jdbc, FinanceCustomerNames customerNames) { this.jdbc = jdbc; + this.customerNames = customerNames; } public ReceivableStatisticsResponse query(YearMonth month, Long customerId, int page, int pageSize) { @@ -61,8 +63,9 @@ public class ReceivableStatisticsService { args.add(customerId); } sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id"); + var names = customerNames.resolver(); return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow( - rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY, + rs.getLong("tenant_id"), names.apply(rs.getLong("tenant_id"), rs.getString("customer_name_snapshot")), CURRENCY, rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"), rs.getBigDecimal("total_outstanding"))), args.toArray()); } 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 34c5c62eb..e4ef8262f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -46,14 +46,16 @@ public class ReconciliationStatementService { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceDocumentNumberGenerator documentNumbers; + private final FinanceCustomerNames customerNames; /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper, - FinanceDocumentNumberGenerator documentNumbers) { + FinanceDocumentNumberGenerator documentNumbers, FinanceCustomerNames customerNames) { this.jdbc = jdbc; this.objectMapper = objectMapper; this.documentNumbers = documentNumbers; + this.customerNames = customerNames; } /** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */ @@ -195,7 +197,7 @@ public class ReconciliationStatementService { 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 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(), + return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), customerNames.resolver().apply(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(), @@ -278,7 +280,7 @@ public class ReconciliationStatementService { private CustomerProfile customer(long customerId) { List rows = jdbc.query("SELECT customer_name_snapshot,credit_days FROM fin_customer_settlement_profile WHERE tenant_id=? AND status='ACTIVE'", (rs, row) -> new CustomerProfile(rs.getString("customer_name_snapshot"), rs.getInt("credit_days")), customerId); - return rows.isEmpty() ? new CustomerProfile("客户" + customerId, 30) : rows.get(0); + return new CustomerProfile(customerNames.required(customerId), rows.isEmpty() ? 30 : rows.get(0).creditDays()); } private long activeSubject() { @@ -488,18 +490,19 @@ public class ReconciliationStatementService { } private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException { - return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")), - amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"), + return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data"), "total"), + amountFromJson(rs.getString("after_data"), "totalReceivable"), rs.getString("reason"), rs.getLong("operator_id"), rs.getString("operator_name_snapshot"), rs.getTimestamp("created_at").toLocalDateTime()); } - private BigDecimal amountFromJson(String value) { - if (!StringUtils.hasText(value)) return BigDecimal.ZERO; + /** 变更前为 Bill,变更后为 ReconciliationDetail;缺失金额属于损坏快照,不能伪装为零。 */ + private BigDecimal amountFromJson(String value, String field) { try { - JsonNode node = objectMapper.readTree(value); - JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total"); - return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue(); + JsonNode node = objectMapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(value); + JsonNode amount = node.get(field); + if (amount == null || !amount.isNumber()) throw new IllegalArgumentException("缺失金额"); + return amount.decimalValue(); } catch (Exception exception) { throw error("FIN_001", "对账单修订快照损坏"); } diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index b99700bb0..de880e17a 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -7,9 +7,16 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.finance.service.BusinessFeeSourceEventService; import com.baiyue.finance.service.FinanceSupplierValidator; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.repository.BusinessFeeSourceRepository; +import com.baiyue.finance.dto.BusinessFeeSourceVersion; +import com.baiyue.common.event.DeliveryAck; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.JdbcTemplate; @@ -25,6 +32,7 @@ import static org.mockito.Mockito.never; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -37,7 +45,8 @@ class BusinessFeeSourceEventServiceTest { private final JdbcTemplate jdbc = mock(JdbcTemplate.class); private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); - private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers); + private final BusinessFeeSourceRepository sources = mock(BusinessFeeSourceRepository.class); + private final BusinessFeeSourceEventService service = new BusinessFeeSourceEventService(jdbc, json, suppliers, sources); @Test void importsOverseasFeeWithoutInventingInboundReference() { @@ -116,19 +125,93 @@ class BusinessFeeSourceEventServiceTest { verify(jdbc, never()).update(anyString(), any(Object[].class)); } + /** 旧版本不能覆盖已有较新版本,客户相同时仍保留原乱序门禁。 */ @Test void lateOlderVersionCannotSupersedeNewerSourceOrCost() { when(suppliers.requireActive(71L, null)).thenReturn( new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); - when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"), - eq("OVERSEAS_CONTAINER_FEE"), eq(701L))) - .thenReturn(List.of(Map.of("source_fee_version", 2))); + when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L)) + .thenReturn(List.of(new BusinessFeeSourceVersion(2, 10001L))); assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1))) .hasMessageContaining("版本已过期"); verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class)); } + /** 已完成的同一事件不再查询会停用、改名或暂时不可用的供应商主档。 */ + @ParameterizedTest + @ValueSource(strings = {"FIN_409", "FIN_503", "RENAMED"}) + void completedReplayDoesNotDependOnCurrentSupplier(String change) { + EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); + when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId()))) + .thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L))); + if ("RENAMED".equals(change)) { + when(suppliers.requireActive(71L, null)).thenReturn( + new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "新供应商名称", null, "ACTIVE")); + } else { + when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(change, "供应商不可用")); + } + assertThat(service.consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE); + verify(suppliers, never()).requireActive(anyLong(), any()); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + /** 首次接收仍须失败关闭,不能为修复重放而绕过供应商校验。 */ + @ParameterizedTest + @ValueSource(strings = {"FIN_409", "FIN_503"}) + void newPayableStillRejectsUnavailableSupplier(String code) { + EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); + when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(code, "供应商不可用")); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("供应商不可用"); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + /** 两个摘要均正确也不能绕过金额算术、人民币汇率及精度约束。 */ + @ParameterizedTest + @CsvSource({"USD,1.00,8.000000,120.00", "CNY,120.00,2.000000,240.00", + "USD,1.00,8.000000,8.001", "USD,1.001,8.000000,8.01", "USD,1.00,8.0000001,8.00"}) + void rejectsInconsistentAmountOrPrecision(String currency, String original, String rate, String total) { + EventEnvelope event = convertedEvent(currency, original, rate, total); + assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-019")); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + /** 分位舍入与数值相等的尾零均遵循生产端规则,不错误拒收合法费用。 */ + @ParameterizedTest + @CsvSource({"USD,0.04,0.125000,0.01", "USD,16.85,7.122000,120.01", "CNY,120.0000,1.00000000,120.0000"}) + void acceptsProducerRoundingAndEquivalentTrailingZeros(String currency, String original, String rate, String total) { + when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), + eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L); + assertThat(service.consume(convertedEvent(currency, original, rate, total)).outcome()) + .isEqualTo(DeliveryAck.Outcome.APPLIED); + } + + /** 调整只允许原客户,错误归属不得替代旧费用或旧成本。 */ + @Test + void crossCustomerRevisionCannotReplaceExistingSource() { + var event = payableEvent("ADJUSTED", 2); + when(sources.lockVersions("LASTLEG", "OVERSEAS_CONTAINER_FEE", 701L)) + .thenReturn(List.of(new BusinessFeeSourceVersion(1, 10002L))); + assertThatThrownBy(() -> service.consume(event)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-018")); + noFinancialWrites(); + } + + /** 创建金额测试快照时同时更新正式来源摘要与事件摘要。 */ + private EventEnvelope convertedEvent(String currency, String original, String rate, String total) { + EventEnvelope base = eventWithoutInbound(); + ObjectNode payload = base.payload().deepCopy(); + payload.put("originalCurrency", currency).put("originalAmount", new BigDecimal(original)) + .put("exchangeRate", new BigDecimal(rate)).put("totalAmount", new BigDecimal(total)); + payload.putNull("action"); payload.putNull("sourceHash"); + payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + payload.put("action", "PUBLISHED"); + return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(), + base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + @Test void replayReturnsOriginalSourceWithoutDuplicatingCost() { EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1); diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java new file mode 100644 index 000000000..138aadf89 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceReceptionPersistenceTest.java @@ -0,0 +1,238 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot; +import com.baiyue.finance.mapper.BusinessFeeSourceMapper; +import com.baiyue.finance.repository.BusinessFeeSourceRepository; +import com.baiyue.finance.service.BusinessFeeSourceEventService; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 隔离数据库执行真实 Mapper 和接收事务,验证失败不留下收件箱或费用半成品。 */ +class BusinessFeeSourceReceptionPersistenceTest { + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final FinanceSupplierValidator suppliers = mock(FinanceSupplierValidator.class); + private JdbcTemplate jdbc; + private TransactionTemplate transaction; + private BusinessFeeSourceEventService service; + + /** MyBatis 与既有接收写入共用 Spring 事务连接,不连接共享 TEST 数据库。 */ + @BeforeEach + void setUp() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:fee-reception-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(data); + jdbc.execute(""" + CREATE TABLE fin_fee_source(id BIGINT AUTO_INCREMENT PRIMARY KEY, + source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT, + customer_id BIGINT,source_no VARCHAR(64),business_type VARCHAR(32),business_id BIGINT, + business_no VARCHAR(128),inbound_code VARCHAR(64),warehouse_id BIGINT,direction VARCHAR(16), + order_id BIGINT,inbound_id BIGINT,container_id BIGINT,container_booking_id BIGINT,container_no VARCHAR(64), + fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),amount DECIMAL(20,6),currency_code VARCHAR(3), + settlement_mode VARCHAR(16),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128), + payment_voucher_file_ids VARCHAR(2000),source_hash VARCHAR(71),source_snapshot VARCHAR(10000), + review_status VARCHAR(16),status VARCHAR(16),wallet_hold_id BIGINT,operation_version INT DEFAULT 0, + UNIQUE(source_system,source_type,source_fee_id,source_fee_version)) + """); + jdbc.execute(""" + CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64), + payload VARCHAR(15000),payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT, + processed_at TIMESTAMP,error_code VARCHAR(64),PRIMARY KEY(source_system,event_id)) + """); + jdbc.execute(""" + CREATE TABLE fin_cost(id BIGINT AUTO_INCREMENT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(200), + cost_type VARCHAR(32),cost_origin VARCHAR(32),source_system VARCHAR(32),source_type VARCHAR(32), + source_fee_id BIGINT,source_fee_version INT,source_hash VARCHAR(71),order_id BIGINT,inbound_id BIGINT, + container_id BIGINT,warehouse_id BIGINT,supplier_id BIGINT,supplier_name_snapshot VARCHAR(128), + fee_item_code VARCHAR(64),fee_item_name VARCHAR(200),currency_code VARCHAR(3),cost_amount DECIMAL(20,6), + voucher_file_ids VARCHAR(2000),paid_at TIMESTAMP,payment_method VARCHAR(32),payment_reference_no VARCHAR(128), + status VARCHAR(16),confirmed_at TIMESTAMP,created_by BIGINT,created_by_name VARCHAR(64),updated_by BIGINT, + voided_at TIMESTAMP,void_reason VARCHAR(200),version INT DEFAULT 0, + UNIQUE(source_system,source_type,source_fee_id,source_fee_version)) + """); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT,tenant_id BIGINT,status VARCHAR(16))"); + jdbc.execute("CREATE TABLE fin_bill_line(bill_id BIGINT,tenant_id BIGINT,source_id BIGINT,source_version INT)"); + jdbc.execute("CREATE TABLE fin_settlement(id BIGINT,tenant_id BIGINT,status VARCHAR(16))"); + jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)"); + var configuration = new Configuration(); + configuration.addMapper(BusinessFeeSourceMapper.class); + var factory = new SqlSessionFactoryBean(); + factory.setDataSource(data); + factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(BusinessFeeSourceMapper.class); + service = new BusinessFeeSourceEventService(jdbc, json, suppliers, new BusinessFeeSourceRepository(mapper)); + transaction = new TransactionTemplate(new DataSourceTransactionManager(data)); + when(suppliers.requireActive(71L, null)).thenReturn(new SupplierSnapshot(71L, "供应商", null, "ACTIVE")); + } + + /** 释放每个用例独占的内存数据库。 */ + @AfterEach + void tearDown() { + if (jdbc != null) jdbc.execute("SHUTDOWN"); + } + + /** 已提交后模拟丢失回执,供应商停用或故障都不产生第二笔费用和成本。 */ + @ParameterizedTest + @ValueSource(strings = {"FIN_409", "FIN_503"}) + void committedDirectPaidReplayKeepsExactlyOneSourceAndCost(String errorCode) { + var event = event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00"); + assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + reset(suppliers); + when(suppliers.requireActive(71L, null)).thenThrow(new FinanceEventException(errorCode, "供应商不可用")); + + assertThat(consume(event).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE); + verifyNoInteractions(suppliers); + assertCounts(1, 1, 1); + assertThat(jdbc.queryForObject("SELECT status FROM fin_inbox_event", String.class)).isEqualTo("PROCESSED"); + assertThat(jdbc.queryForObject("SELECT cost_amount FROM fin_cost", BigDecimal.class)).isEqualByComparingTo("12.00"); + } + + /** 不同客户的升版失败时回滚已插入 Inbox,原费用与原直付成本保持有效。 */ + @Test + void foreignCustomerRevisionRollsBackAndSameCustomerCanStillAdjust() { + consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00")); + var wrong = event(701, 2, 10002, "DIRECT_PAID", "CNY", "18.00", "1", "18.00"); + assertThatThrownBy(() -> consume(wrong)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-018")); + assertCounts(1, 1, 1); + assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE"); + assertThat(jdbc.queryForObject("SELECT status FROM fin_cost", String.class)).isEqualTo("CONFIRMED"); + assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source", Long.class)).isEqualTo(10001L); + + consume(event(701, 2, 10001, "DIRECT_PAID", "CNY", "18.00", "1", "18.00")); + assertCounts(2, 2, 2); + assertThat(jdbc.queryForList("SELECT status FROM fin_fee_source ORDER BY source_fee_version", String.class)) + .containsExactly("SUPERSEDED", "AVAILABLE"); + assertThat(jdbc.queryForList("SELECT status FROM fin_cost ORDER BY source_fee_version", String.class)) + .containsExactly("VOIDED", "CONFIRMED"); + } + + /** 错误换算拒收后不残留收件记录,已存在版本也不会被替代。 */ + @Test + void invalidConversionLeavesOriginalAmountsAndReceiptIntact() { + consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00")); + assertThatThrownBy(() -> consume(event(701, 2, 10001, "ON_ACCOUNT", "USD", "1.00", "8", "120.00"))) + .isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-019")); + assertCounts(1, 1, 0); + assertThat(jdbc.queryForObject("SELECT amount FROM fin_fee_source", BigDecimal.class)).isEqualByComparingTo("12.00"); + assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source", String.class)).isEqualTo("AVAILABLE"); + } + + /** 只锁定相同系统、来源类型和费用号,其他客户的独立费用不会阻塞正常升版。 */ + @Test + void independentSourcesKeepSeparateCustomerIdentity() { + consume(event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00")); + consume(event(702, 1, 10002, "ON_ACCOUNT", "CNY", "9.00", "1", "9.00")); + consume(event(701, 2, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00")); + assertCounts(3, 3, 0); + assertThat(jdbc.queryForObject("SELECT customer_id FROM fin_fee_source WHERE source_fee_id=702", Long.class)) + .isEqualTo(10002L); + assertThat(jdbc.queryForObject("SELECT status FROM fin_fee_source WHERE source_fee_id=702", String.class)) + .isEqualTo("AVAILABLE"); + } + + /** 提前恢复幂等结果不放宽事件冲突校验,异载荷仍须拒绝。 */ + @Test + void changedPayloadCannotUseProcessedEventIdentity() { + var first = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "12.00", "1", "12.00"); + consume(first); + var changed = event(701, 1, 10001, "ON_ACCOUNT", "CNY", "18.00", "1", "18.00"); + var conflict = new EventEnvelope(first.eventId(), changed.eventType(), changed.eventVersion(), changed.occurredAt(), + changed.tenantId(), changed.aggregateType(), changed.aggregateId(), changed.aggregateVersion(), null, + changed.traceId(), changed.correlationId(), null, changed.payload(), changed.payloadHash()); + reset(suppliers); + assertThatThrownBy(() -> consume(conflict)).isInstanceOfSatisfying(FinanceEventException.class, + error -> assertThat(error.code()).isEqualTo("FIN-EVT-002")); + verifyNoInteractions(suppliers); + assertCounts(1, 1, 0); + } + + /** 即使失败发生在费用插入之后,收件箱、费用及成本也必须一起回滚。 */ + @Test + void costWriteFailureRollsBackReceiptAndSource() { + jdbc.execute("ALTER TABLE fin_cost ADD CONSTRAINT test_cost_limit CHECK(cost_amount <= 10)"); + assertThatThrownBy(() -> consume(event(701, 1, 10001, "DIRECT_PAID", "CNY", "12.00", "1", "12.00"))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertCounts(0, 0, 0); + } + + /** 以正式服务事务边界执行,Mapper 与遗留 JDBC 写入必须共进退。 */ + private DeliveryAck consume(EventEnvelope event) { + return transaction.execute(status -> service.consume(event)); + } + + /** 校验整笔接收事实数量,防止幂等通过但产生额外财务明细。 */ + private void assertCounts(int inbox, int sources, int costs) { + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Integer.class)).isEqualTo(inbox); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Integer.class)).isEqualTo(sources); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_cost", Integer.class)).isEqualTo(costs); + } + + /** 构造经过两个正式摘要计算的费用快照,金额错误用例不会被摘要错误提前拦截。 */ + private EventEnvelope event(long feeId, int version, long customer, String mode, String currency, + String original, String rate, String total) { + ObjectNode payload = json.createObjectNode(); + payload.putNull("action"); + payload.put("sourceSystem", "LASTLEG").put("sourceType", "OVERSEAS_WAREHOUSE_FEE") + .put("sourceFeeId", String.valueOf(feeId)).put("sourceVersion", version) + .put("customerId", String.valueOf(customer)).put("customerName", "验收客户") + .put("sourceNo", "ZY-RECEPTION").put("businessType", "WAREHOUSE_SERVICE") + .put("businessId", "801").put("businessNo", "ZY-RECEPTION"); + for (String field : new String[]{"orderId", "inboundId", "inboundCode", "containerId", "containerBookingId", "containerNo"}) + payload.putNull(field); + payload.put("warehouseId", "91").put("feeItemCode", "FEE_TYPE_003").put("feeItemName", "人工费") + .put("direction", "PAYABLE").put("supplierId", "71").put("supplierName", "供应商"); + payload.putNull("supplierCategory"); payload.put("settlementMode", mode); + if ("DIRECT_PAID".equals(mode)) { + payload.set("paidAt", json.valueToTree(Instant.parse("2026-10-27T01:00:00Z"))); + payload.put("paymentMethod", "BANK_TRANSFER").put("paymentReferenceNo", "TEST-RECEPTION"); + } else { + payload.putNull("paidAt"); payload.putNull("paymentMethod"); payload.putNull("paymentReferenceNo"); + } + payload.putArray("paymentVoucherFileIds"); + payload.put("businessDate", "2026-10-27").put("currency", "CNY").put("totalAmount", new BigDecimal(total)) + .put("originalCurrency", currency).put("originalAmount", new BigDecimal(original)) + .put("exchangeRate", new BigDecimal(rate)).put("exchangeRateMonth", "2026-10"); + for (String field : new String[]{"chargeableQuantity", "quantityUnit", "unitPrice", "periodStart", "periodEnd"}) + payload.putNull(field); + payload.put("reviewStatus", "APPROVED").put("publicationStatus", "PUBLISHED").put("sourceStatus", "ACTIVE"); + payload.putNull("sourceHash"); payload.putNull("walletSettlement"); + // 使用正式 DTO 规范化日期等类型,和生产者的序列化输入保持一致。 + payload = json.valueToTree(json.convertValue(payload, BusinessFeeSourceChangedV1.class)); + payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + payload.put("action", version == 1 ? "PUBLISHED" : "ADJUSTED"); + return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, + Instant.parse("2026-10-27T01:00:00Z"), String.valueOf(customer), "BUSINESS_FEE", String.valueOf(feeId), version, + null, UUID.randomUUID().toString(), "TEST:FEE:" + feeId + ':' + version, null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java index 7e4737ea7..690edc56e 100644 --- a/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/CustomerAccountSummaryBehaviorTest.java @@ -37,7 +37,7 @@ class CustomerAccountSummaryBehaviorTest { when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L))) .thenReturn(List.of()); - var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L); + var result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccount(9L); assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER"); var amountSql = org.mockito.ArgumentCaptor.forClass(String.class); @@ -60,7 +60,7 @@ class CustomerAccountSummaryBehaviorTest { ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00"); when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); CustomerAccountSummary result = service.customerAccountSummary(9L); @@ -99,7 +99,7 @@ class CustomerAccountSummaryBehaviorTest { when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation -> ((RowMapper) invocation.getArgument(1)).mapRow(rs, 0)); - CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L); + CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(77L); assertThat(result.totalReceivable()).isZero(); assertThat(result.receivedAmount()).isZero(); @@ -116,7 +116,7 @@ class CustomerAccountSummaryBehaviorTest { when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null)); - assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L)) + assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()).customerAccountSummary(88L)) .isInstanceOf(BadSqlGrammarException.class); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java new file mode 100644 index 000000000..57cc7c4a4 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTest.java @@ -0,0 +1,53 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.service.FinanceCustomerNames; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FinanceCustomerNamesTest { + private final IamCustomerClient iam = mock(IamCustomerClient.class); + private final FinanceCustomerNames names = new FinanceCustomerNames(iam, "internal-token"); + + @Test + void resolvesInternalLabelsAndMissingNamesOncePerCustomerWithoutReplacingValidSnapshots() { + respond(9, "当前公司名称"); + var resolver = names.resolver(); + assertThat(resolver.apply(9L, "客户9")).isEqualTo("当前公司名称"); + assertThat(resolver.apply(9L, "9")).isEqualTo("当前公司名称"); + assertThat(resolver.apply(9L, null)).isEqualTo("当前公司名称"); + assertThat(resolver.apply(9L, "原单公司名称")).isEqualTo("原单公司名称"); + verify(iam, times(1)).page("internal-token", 1, 1, null, null, 9L); + } + + @Test + void missingCustomerDisplaysDashButCannotCreateNewDocument() { + when(iam.page("internal-token", 1, 1, null, null, 9L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 1, 0, 0))); + assertThat(names.resolver().apply(9L, "客户9")).isEqualTo("—"); + assertThatThrownBy(() -> names.requiredSnapshot(9, "客户9")).isInstanceOf(FinanceEventException.class).hasMessageContaining("无法生成"); + } + + @Test + void rejectsMismatchedCustomerInsteadOfLeakingAnotherCompanyName() { + respond(10, "其他公司"); + assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("范围异常"); + } + + @Test + void dependencyFailureCannotFallBackToInternalIdOrSilentlyCreateDocument() { + when(iam.page("internal-token", 1, 1, null, null, 9L)).thenThrow(new IllegalStateException("offline")); + assertThatThrownBy(() -> names.required(9)).isInstanceOf(FinanceEventException.class).hasMessageContaining("暂不可用"); + assertThat(names.resolver().apply(9L, "已记录公司名称")).isEqualTo("已记录公司名称"); + } + + private void respond(long returnedId, String name) { + when(iam.page("internal-token", 1, 1, null, null, 9L)).thenReturn(ApiResponse.success( + new PageResult<>(List.of(new IamCustomerClient.Customer(returnedId, "TEST", name, "ACTIVE", null, null)), 1, 1, 1, 1))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java new file mode 100644 index 000000000..18b6d5d8f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerNamesTestSupport.java @@ -0,0 +1,22 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.service.FinanceCustomerNames; +import java.util.List; +import static org.mockito.Mockito.mock; + +/** 财务服务单测使用真实名称解析器及隔离的 IAM 主档,不请求测试环境。 */ +public final class FinanceCustomerNamesTestSupport { + private FinanceCustomerNamesTestSupport() {} + + public static FinanceCustomerNames names() { + var iam = mock(IamCustomerClient.class, invocation -> { + if (!invocation.getMethod().getName().equals("page")) return org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation); + long id = invocation.getArgument(5); + return ApiResponse.success(new PageResult<>(List.of(new IamCustomerClient.Customer(id, "TEST", "测试客户公司", "ACTIVE", null, null)), 1, 1, 1, 1)); + }); + return new FinanceCustomerNames(iam, "internal-token"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java b/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java index 11c995fd1..53171d97c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceDirectPaidCostReadTest.java @@ -31,7 +31,7 @@ class FinanceDirectPaidCostReadTest { jdbc.execute("CREATE TABLE fin_cost_allocation(id BIGINT,tenant_id BIGINT,cost_id BIGINT,order_id BIGINT," + "inbound_id BIGINT,volume_cbm DECIMAL(18,6),total_volume_cbm DECIMAL(18,6),allocation_rate DECIMAL(18,6)," + "allocated_amount DECIMAL(18,2),rounding_adjustment BOOLEAN)"); - service = new FinanceQueryService(jdbc, new ObjectMapper()); + service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); } /** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */ diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java index 8806fff05..953de366f 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeePoolDirectionBehaviorTest.java @@ -18,7 +18,7 @@ class FinanceFeePoolDirectionBehaviorTest { @Test void receivablePoolNeverContainsPayableSource() { DirectionJdbc jdbc = new DirectionJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); List result = service.feePool(7L, "RECEIVABLE"); @@ -30,7 +30,7 @@ class FinanceFeePoolDirectionBehaviorTest { @Test void payablePoolNeverContainsReceivableSource() { DirectionJdbc jdbc = new DirectionJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); List result = service.feePool(7L, "PAYABLE"); @@ -42,7 +42,7 @@ class FinanceFeePoolDirectionBehaviorTest { @Test void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() { DirectionJdbc jdbc = new DirectionJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); service.feePool(7L, "PAYABLE", 81L); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java index c48e46ab7..bbde7617a 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java @@ -15,6 +15,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource; import java.math.BigDecimal; import java.time.LocalDate; import java.util.UUID; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -30,8 +31,11 @@ public class FinanceFeeSourceReadMapperTest { try (var connection = data.getConnection()) { jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'"); jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'"); - jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)"); - jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)"); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP,settlement_mode VARCHAR(32),paid_at TIMESTAMP,operation_version INT DEFAULT 0,review_reason VARCHAR(128),source_hash VARCHAR(64))"); + jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT,tenant_id BIGINT,bill_id BIGINT)"); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(24))"); + jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,direction VARCHAR(16),status VARCHAR(24),payment_status VARCHAR(24))"); + jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)"); jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)"); insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00"); insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00"); @@ -103,10 +107,44 @@ public class FinanceFeeSourceReadMapperTest { assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly( "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8); - jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)"); - filters.put("status", "BILLED"); filters.put("statementGenerated", true); - assertThat(repository.count(filters)).isEqualTo(1); - assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); + if ("RECEIVABLE".equals(direction)) { + jdbc.update("INSERT INTO fin_bill VALUES(91,9,'ISSUED')"); + jdbc.update("INSERT INTO fin_bill_line VALUES(2,2,9,91)"); + filters.put("status", "BILLED"); filters.put("statementGenerated", true); + session.clearCache(); + assertThat(repository.count(filters)).isEqualTo(1); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("BILLED"); + jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=91"); + session.clearCache(); + assertThat(repository.detail(2).statementGenerated()).isFalse(); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE"); + } else { + jdbc.update("INSERT INTO fin_settlement VALUES(91,9,'PAYABLE','ISSUED','UNPAID')"); + jdbc.update("INSERT INTO fin_settlement_line VALUES(91,9,2)"); + for (String payment : List.of("UNPAID", "PARTIALLY_PAID", "PAID")) { + jdbc.update("UPDATE fin_settlement SET payment_status=? WHERE id=91", payment); + String expected = "UNPAID".equals(payment) ? "PAYABLE_BILLED" : payment; + filters.put("status", expected); + assertThat(repository.count(filters)).isEqualTo(1); + assertThat(repository.list(filters)).singleElement().satisfies(row -> { + assertThat(row.feeSourceId()).isEqualTo(2); + assertThat(row.sourceStatus()).isEqualTo(expected); + assertThat(row.statementGenerated()).isFalse(); + assertThat(row.amount()).isEqualByComparingTo("120"); + }); + assertThat(repository.detail(2).sourceStatus()).isEqualTo(expected); + } + jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=91"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE"); + jdbc.update("UPDATE fin_settlement SET status='PAID',tenant_id=10 WHERE id=91"); + jdbc.update("UPDATE fin_settlement_line SET tenant_id=10 WHERE settlement_id=91"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE"); + jdbc.update("UPDATE fin_fee_source SET settlement_mode='DIRECT_PAID',paid_at='2026-10-21 10:00:00' WHERE id=2"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("PAID"); + jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=2"); + assertThat(repository.detail(2).sourceStatus()).isEqualTo("VOIDED"); + } // 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。 insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00"); diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index fa4cdff2d..43187b5eb 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -189,8 +189,8 @@ class FinancePhase1OperationBehaviorTest { /** 保留费用审核状态读取。 */ @Test void approvedFeeCannotBeEditedAsDraft() throws Exception { - JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 2)); assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED"); } @@ -199,7 +199,7 @@ class FinancePhase1OperationBehaviorTest { @Test void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { FinancePhase1OperationService service = new FinancePhase1OperationService( - feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + mock(JdbcTemplate.class), objectMapper(), null, "", feeSources("APPROVED", true, 2)); FeeSourceDetail detail = service.getFinanceFeeSource(88L); @@ -212,8 +212,8 @@ class FinancePhase1OperationBehaviorTest { /** 业务来源费用必须回到所属系统调整。 */ @Test void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception { - JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", feeSources("REJECTED", false, 1)); var request = new FinancePhase1Dtos.FeeSourceEditRequest( new BigDecimal("120.00"), "更正金额", 1); @@ -228,7 +228,7 @@ class FinancePhase1OperationBehaviorTest { /** 头程作废等待权威来源事件,不能直接改财务副本。 */ @Test void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception { - JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2); + JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of()); FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class); @@ -236,7 +236,7 @@ class FinancePhase1OperationBehaviorTest { .thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult( "INBOUND_FEE", 700L, 3, "VOIDED", false))); FinancePhase1OperationService service = new FinancePhase1OperationService( - jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class)); + jdbc, objectMapper(), firstleg, "internal-token", feeSources("APPROVED", false, 2)); FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance", "void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2)); @@ -262,13 +262,9 @@ class FinancePhase1OperationBehaviorTest { void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); AtomicInteger reads = new AtomicInteger(); - when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); - String review = reads.getAndIncrement() == 0 ? "PENDING" : "APPROVED"; - int version = "PENDING".equals(review) ? 0 : 1; - return List.of(mapper.mapRow(resultSet(review, false, version), 0)); - }); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); + var sources = mock(FinanceFeeSourceReadRepository.class); + when(sources.detail(88L)).thenAnswer(invocation -> reads.getAndIncrement() == 0 + ? feeDetail("PENDING", false, 0) : feeDetail("APPROVED", false, 1)); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), @@ -280,7 +276,7 @@ class FinancePhase1OperationBehaviorTest { eq(71L), eq("操作人 71"), any(), any())).thenReturn(1); when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", sources); FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key", new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); @@ -332,45 +328,30 @@ class FinancePhase1OperationBehaviorTest { .when(service).getFinanceFeeSource(88L); assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true)) .isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据"); + for (String occupied : List.of("PAYABLE_BILLED", "PARTIALLY_PAID", "PAID")) { + doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("sourceStatus", occupied), FeeSourceDetail.class)) + .when(service).getFinanceFeeSource(88L); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "occupied-" + occupied, request, true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("费用已被占用、结清或作废"); + } } - /** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */ - private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); - return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0)); - }); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); - lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), - eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); - return jdbc; + /** 服务行为测试由 Repository 返回明确快照;正式 SQL 与 DTO 映射由 Mapper 数据库测试覆盖。 */ + private static FinanceFeeSourceReadRepository feeSources(String review, boolean generated, int version) throws Exception { + var repository = mock(FinanceFeeSourceReadRepository.class); + when(repository.detail(88L)).thenReturn(feeDetail(review, generated, version)); + return repository; } - private static ResultSet resultSet(String reviewStatus, boolean included, int version) throws Exception { - ResultSet rs = mock(ResultSet.class); - when(rs.getLong("id")).thenReturn(88L); - when(rs.getLong("source_fee_id")).thenReturn(700L); - when(rs.getLong("customer_id")).thenReturn(9L); - when(rs.getLong("order_id")).thenReturn(90L); - when(rs.getLong("inbound_id")).thenReturn(100L); - when(rs.getLong("container_id")).thenReturn(200L); - when(rs.wasNull()).thenReturn(false, false, true, false, false, true); - when(rs.getInt("source_fee_version")).thenReturn(3); - when(rs.getString("source_system")).thenReturn("FIRSTLEG"); - when(rs.getString("source_no")).thenReturn("IN-100"); - when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); - when(rs.getString("direction")).thenReturn("RECEIVABLE"); - when(rs.getString("fee_item_name")).thenReturn("头程运费"); - when(rs.getString("currency_code")).thenReturn("CNY"); - when(rs.getString("status")).thenReturn("AVAILABLE"); - when(rs.getString("source_hash")).thenReturn("a".repeat(64)); - when(rs.getString("review_status")).thenReturn(reviewStatus); - when(rs.getString("review_reason")).thenReturn(null); - when(rs.getInt("operation_version")).thenReturn(version); - when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00")); - when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"inboundCode\":\"NYEXX2608180003\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}"); - return rs; + private static FeeSourceDetail feeDetail(String review, boolean generated, int version) throws Exception { + var node = objectMapper().createObjectNode().put("feeSourceId",88).put("sourceSystem","FIRSTLEG") + .put("sourceType","INBOUND_FEE").put("sourceFeeId",700).put("sourceVersion",3).put("customerId",9) + .put("direction","RECEIVABLE").put("orderId",90).put("inboundId",100).put("containerId",200) + .put("inboundCode","NYEXX2608180003").put("sourceBusinessId","IN-100").put("feeItemName","头程运费") + .put("amount",100).put("currency","CNY").put("operationVersion",version).put("reviewStatus",review) + .put("sourceStatus",generated ? "BILLED" : "AVAILABLE").put("statementGenerated",generated) + .put("sourceHash","a".repeat(64)).put("businessDate","2026-08-11"); + return objectMapper().treeToValue(node,FeeSourceDetail.class); } private static ObjectMapper objectMapper() { diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java index c54a04c07..5e5efc42d 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformOptionalCustomerQueryTest.java @@ -16,7 +16,7 @@ class FinancePlatformOptionalCustomerQueryTest { @Test void allCustomerQueriesOmitCustomerPredicateAndArguments() { RecordingJdbc jdbc = new RecordingJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); service.feePool(null, null); service.statements(null, false); @@ -33,7 +33,7 @@ class FinancePlatformOptionalCustomerQueryTest { @Test void customerFilterAddsPredicateAndRealCustomerArgument() { RecordingJdbc jdbc = new RecordingJdbc(); - FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); service.feePool(31L, null); service.statements(31L, false); @@ -49,7 +49,7 @@ class FinancePlatformOptionalCustomerQueryTest { @Test void optionalCustomerNeverTreatsZeroAsAllCustomers() { - FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper()); + FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper(), FinanceCustomerNamesTestSupport.names()); assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效"); assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index e5923f6ac..c59f4328e 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -100,6 +100,7 @@ class FinanceSettlementBehaviorTest { assertThat(all).singleElement().extracting(SettlementSummary::settlementId).isEqualTo(88L); assertThat(scoped).singleElement().extracting(SettlementSummary::customerId).isEqualTo(10L); + assertThat(scoped).singleElement().extracting(SettlementSummary::customerName).isEqualTo("测试客户公司"); ArgumentCaptor allSql = ArgumentCaptor.forClass(String.class); ArgumentCaptor scopedSql = ArgumentCaptor.forClass(String.class); verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE")); @@ -633,7 +634,7 @@ class FinanceSettlementBehaviorTest { return jdbc.queryForList(sql, invocation.getArguments()); }); return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), - new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper)); + new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names()); } private static FinanceSupplierValidator supplierValidator() { diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java index b6709ad8e..f764ab344 100644 --- a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java @@ -28,7 +28,7 @@ class ReceivableStatisticsServiceTest { 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); + ReceivableStatisticsService service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names()); var result = service.query(YearMonth.of(2026, 7), null, 1, 1); @@ -51,12 +51,35 @@ class ReceivableStatisticsServiceTest { when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) .thenReturn(List.of(row(10001, "客户A", "300.00", "100.00", "199.99"))); - assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc) + assertThatThrownBy(() -> new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names()) .query(YearMonth.of(2026, 7), null, 1, 20)) .isInstanceOf(FinanceEventException.class) .hasMessageContaining("勾稽异常"); } + @Test + @SuppressWarnings("unchecked") + void queryAndExportResolveTheSameCustomerNameWithoutChangingAmounts() throws Exception { + var jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + var rs = mock(java.sql.ResultSet.class); + when(rs.getLong("tenant_id")).thenReturn(9L); + when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); + when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal("300.00")); + when(rs.getBigDecimal("total_received")).thenReturn(new BigDecimal("100.00")); + when(rs.getBigDecimal("total_outstanding")).thenReturn(new BigDecimal("200.00")); + return List.of(mapper.mapRow(rs, 0)); + }); + var service = new ReceivableStatisticsService(jdbc, FinanceCustomerNamesTestSupport.names()); + var query = service.query(YearMonth.of(2026, 10), 9L, 1, 20); + assertThat(query.records()).singleElement().satisfies(row -> { + assertThat(row.customerName()).isEqualTo("测试客户公司"); + assertThat(row.totalReceivable()).isEqualByComparingTo("300.00"); + }); + assertThat(service.exportRows(YearMonth.of(2026, 10), 9L)).isEqualTo(query.records()); + } + 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 f741990ef..2620a8a44 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -83,6 +83,8 @@ class ReconciliationStatementBehaviorTest { @Test void byCutoffSelectsEligibleSourcesAndCreatesDraft() throws Exception { JdbcTemplate jdbc = createJdbc(); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT customer_name_snapshot"), any(RowMapper.class), eq(9L))) + .thenReturn(List.of()); ReconciliationStatementService target = serviceWithNumber(jdbc); CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "BY_CUTOFF", List.of(), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "截止日对账"); @@ -90,7 +92,15 @@ class ReconciliationStatementBehaviorTest { var result = target.create(71L, "cutoff-key", request); assertThat(result.status()).isEqualTo("DRAFT"); + assertThat(result.customerName()).isEqualTo("测试客户公司"); assertThat(result.totalReceivable()).isEqualByComparingTo("100.00"); + var creator = ArgumentCaptor.forClass(PreparedStatementCreator.class); + verify(jdbc).update(creator.capture(), any(KeyHolder.class)); + var connection = mock(java.sql.Connection.class); + var insert = mock(java.sql.PreparedStatement.class); + when(connection.prepareStatement(anyString(), eq(java.sql.Statement.RETURN_GENERATED_KEYS))).thenReturn(insert); + creator.getValue().createPreparedStatement(connection); + verify(insert).setString(4, "测试客户公司"); verify(jdbc).query(org.mockito.ArgumentMatchers.startsWith("SELECT id,source_fee_id"), any(RowMapper.class), eq(9L), eq(LocalDate.of(2026, 8, 1)), eq(LocalDate.of(2026, 8, 31))); } @@ -589,9 +599,30 @@ class ReconciliationStatementBehaviorTest { }; } + /** 审批前后使用不同 DTO,实际序列化后的详情金额必须完整返回而非变成零。 */ + @Test + void revisionReadsBeforeBillAndAfterDetailAmountsAndRejectsMissingAmount() throws Exception { + var service = serviceWithNumber(mock(JdbcTemplate.class)); + var rs = mock(ResultSet.class); + when(rs.getString("action")).thenReturn("RECONCILIATION_ADJUST"); + when(rs.getString("before_data")).thenReturn("{\"total\":240.00}"); + when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":240.03}"); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf("2026-10-27 10:00:00")); + com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision revision = + ReflectionTestUtils.invokeMethod(service, "revision", rs, 1); + assertThat(revision.beforeAmount()).isEqualByComparingTo("240.00"); + assertThat(revision.afterAmount()).isEqualByComparingTo("240.03"); + when(rs.getString("after_data")).thenReturn("{\"totalReceivable\":9999999999999999.99}"); + revision = ReflectionTestUtils.invokeMethod(service, "revision", rs, 1); + assertThat(revision.afterAmount()).isEqualByComparingTo("9999999999999999.99"); + when(rs.getString("after_data")).thenReturn("{}"); + assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "revision", rs, 1)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("快照损坏"); + } + private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) { FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class); org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001"); - return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers); + return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers, FinanceCustomerNamesTestSupport.names()); } } 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 663f1c3e9..12b66abcd 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -85,6 +85,26 @@ class FinanceExportProviderServiceTest { assertThat(continued.snapshotToken()).isEqualTo(first.snapshotToken()); } + @Test + void exportsPaidSourcesUsingTheSameProjectionAndFiltersAsTheList() throws Exception { + var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + var source = mapper.treeToValue(mapper.createObjectNode().put("feeSourceId", 81).put("sourceFeeId", 9001) + .put("sourceVersion", 2).put("sourceSystem", "FIRSTLEG").put("sourceType", "CONTAINER_FEE") + .put("direction", "PAYABLE").put("customerId", 10001).put("customerName", "客户A") + .put("feeItemName", "装车费").put("amount", 300).put("currency", "CNY") + .put("reviewStatus", "APPROVED").put("sourceStatus", "PAID").put("statementGenerated", false), + com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary.class); + when(operationService.listFinanceFeeSources(1, 200, null, "PAYABLE", "PAID", null, null, null, null, null)) + .thenReturn(PageEnvelope.success(List.of(source), 1, 200, 1)); + var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST, + request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null)); + assertThat(result.rows()).singleElement().satisfies(row -> { + assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001"); + assertThat(row.get("amount")).isEqualTo(new BigDecimal("300")); + }); + org.mockito.Mockito.verifyNoInteractions(queryService); + } + @Test void rejectsUnknownParametersAndSnapshotReuseAcrossFilters() { assertThatThrownBy(() -> service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, -- 2.54.0 From fe89b8a2b875d6634b0d7f2b28793b73c02bb4b6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 27 Oct 2026 14:56:20 +0800 Subject: [PATCH 16/70] fix(finance): revalidate source ownership before reopening settlements --- .../mapper/FinanceSettlementStateMapper.java | 52 ++++++ .../FinanceSettlementStateRepository.java | 36 ++++ .../service/FinanceSettlementService.java | 102 +++++++++-- .../FinanceSettlementBehaviorTest.java | 162 +++++++++++++++++- .../FinanceSettlementStateMapperTest.java | 123 +++++++++++++ 5 files changed, 451 insertions(+), 24 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementStateMapper.java create mode 100644 finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementStateRepository.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceSettlementStateMapperTest.java diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementStateMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementStateMapper.java new file mode 100644 index 000000000..c022c76cc --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementStateMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.util.List; +import java.util.Map; + +/** 结算状态变更与重新占用来源的事务锁;锁定读取不得复用会话缓存。 */ +@Mapper +public interface FinanceSettlementStateMapper { + /** 锁定待作废或重开的结算,保留客户与来源关联。 */ + @Select("SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version,direction,currency_code,supplier_id,reconciliation_statement_id FROM fin_settlement WHERE id=#{settlementId} FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockSettlement(@Param("settlementId") long settlementId); + + /** 直接锁定来源表,不能依赖外连接右侧记录的锁语义。 */ + @Select("SELECT f.id FROM fin_fee_source f WHERE EXISTS (SELECT 1 FROM fin_settlement_line sl WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} AND sl.fee_source_id=f.id) ORDER BY f.id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List lockSourceRows(@Param("customerId") long customerId, @Param("settlementId") long settlementId); + + /** 按来源顺序锁定原单全部明细,缺失或跨客户来源仍返回明细供服务拒绝。 */ + @Select("SELECT sl.fee_source_id,sl.amount line_amount,sl.currency_code line_currency,f.id,f.customer_id,f.direction,f.currency_code,f.amount,f.review_status,f.status,f.settlement_mode,f.source_snapshot FROM fin_settlement_line sl LEFT JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.fee_source_id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockSources(@Param("customerId") long customerId, @Param("settlementId") long settlementId); + + /** 使用当前锁定读检查其他有效结算,避免等待来源锁后读取旧快照。 */ + @Select("SELECT s.id FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=#{customerId} AND sl.fee_source_id=#{sourceId} AND s.id<>#{settlementId} AND s.status<>'VOIDED' FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockOtherSourceSettlements(@Param("customerId") long customerId, @Param("sourceId") long sourceId, @Param("settlementId") long settlementId); + + /** 与应收新建共同锁定原对账单,防止检查后被调整或再次生成应收。 */ + @Select("SELECT id,tenant_id,status,currency_code,total_amount,received_amount FROM fin_bill WHERE tenant_id=#{customerId} AND id=#{billId} FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockBill(@Param("customerId") long customerId, @Param("billId") long billId); + + /** 读取原账单当前明细,重开不能沿用调账前的行金额。 */ + @Select("SELECT source_id fee_source_id,amount line_amount,currency_code line_currency FROM fin_bill_line WHERE tenant_id=#{customerId} AND bill_id=#{billId} ORDER BY source_id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockBillLines(@Param("customerId") long customerId, @Param("billId") long billId); + + /** 锁定同账单其他有效应收,作废旧单不可绕过唯一占用。 */ + @Select("SELECT id FROM fin_settlement WHERE tenant_id=#{customerId} AND direction='RECEIVABLE' AND reconciliation_statement_id=#{billId} AND id<>#{settlementId} AND status<>'VOIDED' FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockOtherBillSettlements(@Param("customerId") long customerId, @Param("billId") long billId, @Param("settlementId") long settlementId); + + /** 客户范围内按原版本推进状态。 */ + @Update("UPDATE fin_settlement SET status=#{status},updated_by=#{operatorId},version=version+1 WHERE id=#{settlementId} AND tenant_id=#{customerId} AND version=#{version}") + int changeStatus(@Param("status") String status, @Param("operatorId") long operatorId, @Param("settlementId") long settlementId, @Param("customerId") long customerId, @Param("version") int version); +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementStateRepository.java b/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementStateRepository.java new file mode 100644 index 000000000..1a5b909d2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementStateRepository.java @@ -0,0 +1,36 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.mapper.FinanceSettlementStateMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.LinkedCaseInsensitiveMap; +import java.util.List; +import java.util.Map; + +/** 封装结算状态及原始费用、账单的事务访问。 */ +@Repository +public class FinanceSettlementStateRepository { + private final FinanceSettlementStateMapper mapper; + /** 注入结算状态持久化入口。 */ + public FinanceSettlementStateRepository(FinanceSettlementStateMapper mapper) { this.mapper = mapper; } + /** 统一数据库返回的列名大小写。 */ + private List> rows(List> rows) { + return rows.stream().map(row -> { Map value = new LinkedCaseInsensitiveMap<>(); value.putAll(row); return value; }).toList(); + } + /** 锁定目标结算。 */ + public List> lockSettlement(long id) { return rows(mapper.lockSettlement(id)); } + /** 锁定结算所有费用来源。 */ + public List> lockSources(long customerId, long id) { + mapper.lockSourceRows(customerId, id); + return rows(mapper.lockSources(customerId, id)); + } + /** 判断费用是否被其他有效结算占用。 */ + public boolean sourceOccupied(long customerId, long sourceId, long id) { return !mapper.lockOtherSourceSettlements(customerId, sourceId, id).isEmpty(); } + /** 锁定原始对账单。 */ + public List> lockBill(long customerId, long billId) { return rows(mapper.lockBill(customerId, billId)); } + /** 锁定原始对账单的当前明细。 */ + public List> lockBillLines(long customerId, long billId) { return rows(mapper.lockBillLines(customerId, billId)); } + /** 判断原对账单是否已生成其他有效应收。 */ + public boolean billOccupied(long customerId, long billId, long id) { return !mapper.lockOtherBillSettlements(customerId, billId, id).isEmpty(); } + /** 按客户及版本变更结算状态。 */ + public int changeStatus(String status, long operatorId, long id, long customerId, int version) { return mapper.changeStatus(status, operatorId, id, customerId, version); } +} 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 4259966d4..243bdfbc4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -53,13 +53,15 @@ public class FinanceSettlementService { private final FinanceSupplierValidator supplierValidator; private final FinanceDocumentNumberGenerator documentNumbers; private final FinanceCustomerNames customerNames; + private final com.baiyue.finance.repository.FinanceSettlementStateRepository states; /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, FinanceSupplierValidator supplierValidator, FinanceDocumentNumberGenerator documentNumbers, - com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames) { + com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames, + com.baiyue.finance.repository.FinanceSettlementStateRepository states) { this.jdbc = jdbc; this.details = details; this.objectMapper = objectMapper; @@ -67,6 +69,7 @@ public class FinanceSettlementService { this.supplierValidator = supplierValidator; this.documentNumbers = documentNumbers; this.customerNames = customerNames; + this.states = states; } /** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */ @@ -89,11 +92,9 @@ public class FinanceSettlementService { Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST", "/api/platform/finance/v1/receivable-settlements", request); if (replay != null) return getSettlement(replay); - Integer occupied = jdbc.queryForObject( - "SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " + - "AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class, - customerId, request.reconciliationStatementId()); - if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单"); + // 新建与重开共用当前锁定读,等待来源锁后不能沿用事务开始时的占用快照。 + if (states.billOccupied(customerId, request.reconciliationStatementId(), 0)) + throw error("FIN_409", "对账单已生成有效应收结算单"); Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?", Integer.class, customerId, request.reconciliationStatementId()); if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细"); @@ -148,10 +149,8 @@ public class FinanceSettlementService { throw error("FIN_409", "费用来源未审核、金额无效或状态不可用"); } if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY"); - Integer used = jdbc.queryForObject( - "SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " + - "WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue()); - if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用"); + if (states.sourceOccupied(customerId, number(source, "id").longValue(), 0)) + throw error("FIN_409", "费用来源已被有效结算单占用"); } BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add); if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); @@ -249,13 +248,14 @@ public class FinanceSettlementService { return getSettlement(settlementId); } + /** 作废保留来源关联;重开必须重新校验并锁定当前仍可占用的原始依据。 */ @Transactional public SettlementDetail actionSettlement(long settlementId, long operatorId, String key, SettlementActionRequest request) { validateSettlementAction(request); - Map settlement = one( - "SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " + - "FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); + List> locked = states.lockSettlement(settlementId); + if (locked.isEmpty()) throw error("FIN_404", "结算单不存在"); + Map settlement = locked.get(0); long customerId = number(settlement, "tenant_id").longValue(); Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST", "/api/platform/finance/v1/settlements/" + settlementId + "/actions", request); @@ -274,11 +274,10 @@ public class FinanceSettlementService { if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) { throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开"); } + requireReopenableSources(settlement); targetStatus = "ISSUED"; } - int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " + - "WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId, - request.expectedVersion()); + int changed = states.changeStatus(targetStatus, operatorId, settlementId, customerId, request.expectedVersion()); if (changed != 1) throw error("FIN_409", "结算单版本冲突"); audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement), settlementStateSnapshot(settlement, targetStatus), operatorId); @@ -286,6 +285,77 @@ public class FinanceSettlementService { return getSettlement(settlementId); } + /** 重开沿用原单金额,来源变化必须重新生成结算,不覆盖历史单据。 */ + private void requireReopenableSources(Map settlement) { + long customerId = number(settlement, "tenant_id").longValue(); + long settlementId = number(settlement, "id").longValue(); + if (!CNY.equals(text(settlement, "currency_code")) || decimal(settlement, "total_amount").signum() <= 0) + throw error("FIN_409", "原结算金额或币种已不满足重开条件"); + if ("RECEIVABLE".equals(text(settlement, "direction"))) { + requireReopenableReceivable(settlement); + return; + } + if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "结算方向不支持重开"); + Long supplierId = nullableNumber(settlement, "supplier_id"); + List> sources = states.lockSources(customerId, settlementId); + if (supplierId == null || sources.isEmpty()) throw error("FIN_409", "原应付来源或供应商不完整"); + BigDecimal total = BigDecimal.ZERO; + List> categories = new ArrayList<>(); + for (Map source : sources) { + JsonNode snapshot = parseTree(text(source, "source_snapshot")); + if (source.get("id") == null || number(source, "customer_id").longValue() != customerId + || !"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status")) + || !"APPROVED".equals(text(source, "review_status")) || !"ON_ACCOUNT".equals(text(source, "settlement_mode")) + || !CNY.equals(text(source, "currency_code")) || !CNY.equals(text(source, "line_currency")) + || !supplierId.equals(jsonLong(snapshot, "supplierId")) || decimal(source, "amount").signum() <= 0 + || decimal(source, "amount").compareTo(decimal(source, "line_amount")) != 0) + throw error("FIN_409", "原应付费用已调整、作废或不再满足结算条件,请重新生成结算单"); + if (states.sourceOccupied(customerId, number(source, "fee_source_id").longValue(), settlementId)) + throw error("FIN_409", "原应付费用已被其他有效结算单占用"); + total = total.add(decimal(source, "line_amount")); + Map category = new java.util.HashMap<>(); + category.put("supplier_category", jsonText(snapshot, "supplierCategory")); + categories.add(category); + } + if (total.compareTo(decimal(settlement, "total_amount")) != 0) + throw error("FIN_409", "原应付结算与费用金额不一致,请重新生成结算单"); + supplierValidator.requireActive(supplierId, supplierCategoryConstraint(categories)); + } + + /** 应收重开还须核对原账单当前状态及逐行金额,防止调账后恢复旧应收。 */ + private void requireReopenableReceivable(Map settlement) { + long customerId = number(settlement, "tenant_id").longValue(); + long settlementId = number(settlement, "id").longValue(); + Long billId = nullableNumber(settlement, "reconciliation_statement_id"); + if (billId == null) throw error("FIN_409", "原应收缺少对账单关联"); + List> bills = states.lockBill(customerId, billId); + if (bills.isEmpty()) throw error("FIN_409", "原对账单不存在或不属于当前客户"); + Map bill = bills.get(0); + if (!"ISSUED".equals(text(bill, "status")) || !CNY.equals(text(bill, "currency_code")) + || decimal(bill, "received_amount").signum() != 0 + || decimal(bill, "total_amount").compareTo(decimal(settlement, "total_amount")) != 0) + throw error("FIN_409", "原对账单状态或金额已变化,请重新生成应收结算单"); + if (states.billOccupied(customerId, billId, settlementId)) + throw error("FIN_409", "原对账单已生成其他有效应收结算单"); + List> lines = states.lockBillLines(customerId, billId); + List> sources = states.lockSources(customerId, settlementId); + if (lines.isEmpty() || sources.size() != lines.size()) throw error("FIN_409", "原应收明细与对账单不一致"); + BigDecimal total = BigDecimal.ZERO; + for (Map source : sources) { + var matching = lines.stream().filter(line -> number(line, "fee_source_id").longValue() == number(source, "fee_source_id").longValue()).toList(); + if (source.get("id") == null || number(source, "customer_id").longValue() != customerId + || !"RECEIVABLE".equals(text(source, "direction")) || !"BILLED".equals(text(source, "status")) + || !"APPROVED".equals(text(source, "review_status")) || !CNY.equals(text(source, "currency_code")) + || !CNY.equals(text(source, "line_currency")) || matching.size() != 1 + || !CNY.equals(text(matching.get(0), "line_currency")) + || decimal(source, "line_amount").compareTo(decimal(matching.get(0), "line_amount")) != 0) + throw error("FIN_409", "原应收费用或明细金额已变化,请重新生成结算单"); + total = total.add(decimal(source, "line_amount")); + } + if (total.compareTo(decimal(settlement, "total_amount")) != 0) + throw error("FIN_409", "原应收明细合计与结算金额不一致"); + } + private long insertSettlement(long customerId, String no, String direction, String customerName, Long supplierId, String supplierName, Long reconciliationId, String businessPeriod, String currency, BigDecimal total, diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index c59f4328e..41df1fba1 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -20,6 +20,8 @@ import com.baiyue.finance.service.FinanceSupplierValidator; import com.baiyue.finance.service.FinanceVoucherValidator; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -222,6 +224,143 @@ class FinanceSettlementBehaviorTest { verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); } + /** 作废应付的费用已被另一张有效单占用时,重开必须拒绝,不能形成双重应付。 */ + @Test + void reopeningCannotReclaimASourceUsedByAnotherSettlement() { + Map row = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO, + new BigDecimal("100.00"), 3); + JdbcTemplate jdbc = creationJdbc(row); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L))) + .thenReturn(List.of(row)); + var source = payableSource(10L, 20L); + source.put("fee_source_id", 31L); + source.put("line_amount", new BigDecimal("100.00")); + source.put("line_currency", "CNY"); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L))) + .thenReturn(List.of(source)); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(88L))) + .thenReturn(List.of(Map.of("id", 99L))); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.actionSettlement(88L, 7L, "reopen-used", + new SettlementActionRequest("REOPEN", "验证来源占用", 3)), "FIN_409"); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 未变化且无其他占用的应付原单可以恢复,继续保留原金额与修订审计。 */ + @Test + void unchangedPayableCanReopen() { + JdbcTemplate jdbc = reopenJdbc("PAYABLE"); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertThat(service.actionSettlement(88L, 7L, "reopen-ok", + new SettlementActionRequest("REOPEN", "恢复有效原单", 3)).summary().status()).isEqualTo("ISSUED"); + verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("ISSUED"), eq(7L), eq(88L), eq(10L), eq(3)); + verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"), eq(88L), + eq("REOPEN"), eq("恢复有效原单"), any(), any(), eq(7L), any(), any(), any()); + } + + /** 原应付的失效、金额和归属变化均不能借重开恢复旧快照。 */ + @ParameterizedTest + @ValueSource(strings = {"VOIDED", "SUPERSEDED", "PENDING", "DIRECT_PAID", "CROSS_CUSTOMER", "MISSING", "AMOUNT_CHANGED", "SUPPLIER_CHANGED", "EMPTY"}) + void reopeningRejectsChangedPayableSources(String condition) { + JdbcTemplate jdbc = reopenJdbc("PAYABLE"); + Map source = reopenSource("PAYABLE"); + switch (condition) { + case "VOIDED", "SUPERSEDED" -> source.put("status", condition); + case "PENDING" -> source.put("review_status", "PENDING"); + case "DIRECT_PAID" -> source.put("settlement_mode", "DIRECT_PAID"); + case "CROSS_CUSTOMER" -> source.put("customer_id", 11L); + case "MISSING" -> source.remove("id"); + case "AMOUNT_CHANGED" -> source.put("amount", new BigDecimal("101.00")); + case "SUPPLIER_CHANGED" -> source.put("source_snapshot", "{\"supplierId\":21}"); + default -> { } + } + when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L))) + .thenReturn("EMPTY".equals(condition) ? List.of() : List.of(source)); + assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L, + "reopen-" + condition, new SettlementActionRequest("REOPEN", "核对原始费用", 3)), "FIN_409"); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 应收原账单与明细未变化且仍已出账时,允许恢复原单。 */ + @Test + void unchangedReceivableCanReopen() { + JdbcTemplate jdbc = reopenJdbc("RECEIVABLE"); + assertThat(service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L, + "reopen-ar", new SettlementActionRequest("REOPEN", "恢复原应收", 3)).summary().status()).isEqualTo("ISSUED"); + } + + /** 其他应收占用、对账单作废或调整、已有收款均拒绝恢复旧应收。 */ + @ParameterizedTest + @ValueSource(strings = {"OCCUPIED", "VOIDED_BILL", "ADJUSTED_TOTAL", "ADJUSTED_LINES", "MISSING_BILL", "RECEIPT", "WRONG_SOURCE", "EMPTY"}) + void reopeningRejectsChangedReceivableBasis(String condition) { + JdbcTemplate jdbc = reopenJdbc("RECEIVABLE"); + Map bill = reopenBill(); + if ("VOIDED_BILL".equals(condition)) bill.put("status", "VOIDED"); + if ("ADJUSTED_TOTAL".equals(condition)) bill.put("total_amount", new BigDecimal("101.00")); + if ("RECEIPT".equals(condition)) bill.put("received_amount", new BigDecimal("0.01")); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,status,currency_code"), eq(10L), eq(501L))) + .thenReturn("MISSING_BILL".equals(condition) ? List.of() : List.of(bill)); + if ("OCCUPIED".equals(condition)) + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(88L))) + .thenReturn(List.of(Map.of("id", 99L))); + if (List.of("ADJUSTED_LINES", "WRONG_SOURCE", "EMPTY").contains(condition)) + when(jdbc.queryForList(startsWith("SELECT source_id fee_source_id"), eq(10L), eq(501L))) + .thenReturn("EMPTY".equals(condition) ? List.of() : List.of(Map.of("fee_source_id", "WRONG_SOURCE".equals(condition) ? 32L : 31L, + "line_amount", new BigDecimal("ADJUSTED_LINES".equals(condition) ? "99.00" : "100.00"), "line_currency", "CNY"))); + assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L, + "reopen-ar-" + condition, new SettlementActionRequest("REOPEN", "核对原对账单", 3)), "FIN_409"); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 重放已成功的重开请求恢复当前单据,不再次修改版本或占用来源。 */ + @Test + void successfulReopenReplayDoesNotReacquireSources() { + JdbcTemplate jdbc = reopenJdbc("PAYABLE"); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + var request = new SettlementActionRequest("REOPEN", "恢复原单", 3); + String path = "/api/platform/finance/v1/settlements/88/actions"; + String hash = ReflectionTestUtils.invokeMethod(service, "hash", Map.of("method", "POST", "path", path, "customerId", 10L, "body", request)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_STATE_ACTION"), eq("replay-open"))) + .thenReturn(List.of(Map.of("request_hash", hash, "status", "SUCCEEDED", "result_resource_id", 88L))); + assertThat(service.actionSettlement(88L, 7L, "replay-open", request).summary().status()).isEqualTo("ISSUED"); + verify(jdbc, never()).queryForList(startsWith("SELECT sl.fee_source_id"), (Object[]) any()); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 为重开场景准备完整且默认有效的原单、来源与对账单。 */ + private static JdbcTemplate reopenJdbc(String direction) { + var settlement = settlementRow(direction, "VOIDED", BigDecimal.ZERO, new BigDecimal("100.00"), 3); + settlement.put("reconciliation_statement_id", 501L); + JdbcTemplate jdbc = creationJdbc(settlementRow(direction, "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 4)); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L))) + .thenReturn(List.of(settlement)); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L))) + .thenReturn(List.of(reopenSource(direction))); + if ("RECEIVABLE".equals(direction)) { + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT id,tenant_id,status,currency_code"), eq(10L), eq(501L))) + .thenReturn(List.of(reopenBill())); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT source_id fee_source_id"), eq(10L), eq(501L))) + .thenReturn(List.of(Map.of("fee_source_id", 31L, "line_amount", new BigDecimal("100.00"), "line_currency", "CNY"))); + } + return jdbc; + } + + /** 提供带原结算金额的真实形状费用记录。 */ + private static Map reopenSource(String direction) { + var row = payableSource(10L, 20L); + row.put("direction", direction); + row.put("status", "RECEIVABLE".equals(direction) ? "BILLED" : "AVAILABLE"); + row.put("fee_source_id", 31L); row.put("line_amount", new BigDecimal("100.00")); row.put("line_currency", "CNY"); + return row; + } + + /** 提供已出账且未发生收款的对账单。 */ + private static Map reopenBill() { + return new java.util.HashMap<>(Map.of("id", 501L, "tenant_id", 10L, "status", "ISSUED", "currency_code", "CNY", + "total_amount", new BigDecimal("100.00"), "received_amount", BigDecimal.ZERO)); + } + @Test void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() { JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> @@ -276,7 +415,7 @@ class FinanceSettlementBehaviorTest { Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(0); + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of()); when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); @@ -358,7 +497,7 @@ class FinanceSettlementBehaviorTest { Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-duplicate"))).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of(Map.of("id", 99L))); assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createReceivableSettlement(7L, "ar-duplicate", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); @@ -371,7 +510,7 @@ class FinanceSettlementBehaviorTest { new BigDecimal("100.00"), 0)); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L))); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0); + when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))).thenReturn(List.of()); FinanceSupplierValidator supplierValidator = supplierValidator(); FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator); @@ -422,8 +561,8 @@ class FinanceSettlementBehaviorTest { if ("ineligible".equals(condition)) source.put("review_status", "PENDING"); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-" + condition))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source)); - org.mockito.Mockito.lenient().when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))) - .thenReturn("occupied".equals(condition) ? 1 : 0); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))) + .thenReturn("occupied".equals(condition) ? List.of(Map.of("id", 99L)) : List.of()); assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createPayableSettlement(7L, "ap-" + condition, new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_409"); @@ -579,7 +718,7 @@ class FinanceSettlementBehaviorTest { Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of(Map.of("id", 99L))); FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); assertCode(() -> service.createReceivableSettlement(7L, "ar-key", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); @@ -598,7 +737,7 @@ class FinanceSettlementBehaviorTest { JdbcTemplate occupiedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); when(occupiedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("occupied-key"))).thenReturn(List.of()); when(occupiedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L))); - when(occupiedJdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(1); + when(occupiedJdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))).thenReturn(List.of(Map.of("id", 99L))); FinanceSettlementService occupiedService = service(occupiedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); assertCode(() -> occupiedService.createPayableSettlement(7L, "occupied-key", request), "FIN_409"); } @@ -633,8 +772,15 @@ class FinanceSettlementBehaviorTest { String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"); return jdbc.queryForList(sql, invocation.getArguments()); }); + var stateMapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementStateMapper.class, invocation -> { + var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class); + if (select != null) return jdbc.queryForList(String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"), invocation.getArguments()); + var update = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Update.class); + if (update != null) return jdbc.update(String.join(" ", update.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"), invocation.getArguments()); + return RETURNS_DEFAULTS.answer(invocation); + }); return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), - new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names()); + new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names(), new com.baiyue.finance.repository.FinanceSettlementStateRepository(stateMapper)); } private static FinanceSupplierValidator supplierValidator() { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementStateMapperTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementStateMapperTest.java new file mode 100644 index 000000000..5a86e7b6f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementStateMapperTest.java @@ -0,0 +1,123 @@ +package com.baiyue.finance; + +import com.baiyue.finance.mapper.FinanceSettlementStateMapper; +import com.baiyue.finance.repository.FinanceSettlementStateRepository; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FinanceSettlementStateMapperTest { + /** 真正执行隔离查询,既保留损坏明细供业务拒绝,也不把其他客户占用算入当前客户。 */ + @Test + void originalRowsAndOccupancyStayCustomerScoped() { + var fixture = fixture(); + fixture.jdbc.update("INSERT INTO fin_fee_source VALUES (32,11,'PAYABLE','CNY',100,'APPROVED','AVAILABLE','ON_ACCOUNT','{}')"); + fixture.jdbc.update("INSERT INTO fin_settlement_line VALUES (3,10,88,32,100,'CNY'),(4,11,88,31,100,'CNY')"); + try (var session = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + assertThat(repository.lockSources(10, 88)).hasSize(2); + assertThat(repository.lockSources(10, 88).get(1).get("customer_id")).isEqualTo(11L); + assertThat(repository.sourceOccupied(10, 31, 88)).isFalse(); + assertThat(repository.sourceOccupied(11, 31, 88)).isFalse(); + assertThat(repository.lockBill(10, 501)).hasSize(1); + assertThat(repository.lockBill(11, 501)).isEmpty(); + assertThat(repository.lockBillLines(10, 501)).hasSize(1); + assertThat(repository.lockBillLines(11, 501)).isEmpty(); + } + } + + /** 同一会话重复校验必须读取最新状态,状态写入继续校验客户与版本。 */ + @Test + void stateUpdatesCannotReuseCachedOccupancyOrBypassVersion() throws Exception { + var fixture = fixture(); + try (var session = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + assertThat(repository.sourceOccupied(10, 31, 99)).isFalse(); + assertThat(repository.changeStatus("ISSUED", 7, 88, 11, 3)).isZero(); + assertThat(repository.changeStatus("ISSUED", 7, 88, 10, 2)).isZero(); + assertThat(repository.changeStatus("ISSUED", 7, 88, 10, 3)).isEqualTo(1); + assertThat(repository.sourceOccupied(10, 31, 99)).isTrue(); + assertThat(repository.lockSettlement(88).get(0).get("version")).isEqualTo(4); + assertThat(repository.lockSources(10, 88).get(0).get("status")).isEqualTo("AVAILABLE"); + try (var statement = session.getConnection().createStatement()) { + statement.executeUpdate("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE id=31"); + } + assertThat(repository.lockSources(10, 88).get(0).get("status")).isEqualTo("SUPERSEDED"); + } + } + + /** 同账单唯一应收采用当前读取,排除自身、作废单及其他客户。 */ + @Test + void receivableOccupancyReflectsCurrentActiveSettlement() { + var fixture = fixture(); + fixture.jdbc.update("UPDATE fin_settlement SET direction='RECEIVABLE',reconciliation_statement_id=501,status='ISSUED' WHERE id=88"); + try (var session = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + assertThat(repository.billOccupied(10, 501, 99)).isTrue(); + assertThat(repository.billOccupied(10, 501, 88)).isFalse(); + assertThat(repository.billOccupied(11, 501, 99)).isFalse(); + repository.changeStatus("VOIDED", 7, 88, 10, 3); + assertThat(repository.billOccupied(10, 501, 99)).isFalse(); + } + } + + /** 两个事务争用同一来源时,后到事务须等待并读取先到事务提交后的占用。 */ + @Test + void sourceLockSerializesCompetingReopens() throws Exception { + var fixture = fixture(); + var executor = Executors.newSingleThreadExecutor(); + try (var first = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(first.getMapper(FinanceSettlementStateMapper.class)); + repository.lockSources(10, 88); + var started = new CountDownLatch(1); + var second = executor.submit(() -> { + try (var session = fixture.factory.openSession()) { + var other = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + started.countDown(); + other.lockSources(10, 99); + return other.sourceOccupied(10, 31, 99); + } + }); + assertThat(started.await(2, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> second.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + repository.changeStatus("ISSUED", 7, 88, 10, 3); + first.commit(); + assertThat(second.get(3, TimeUnit.SECONDS)).isTrue(); + } finally { + executor.shutdownNow(); + } + } + + /** 使用独立内存库执行真实 MyBatis SQL,不启动本地业务服务。 */ + private static Fixture fixture() { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:reopen-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,total_amount DECIMAL(18,2),paid_amount DECIMAL(18,2),outstanding_amount DECIMAL(18,2),payment_status VARCHAR(32),status VARCHAR(32),version INT,direction VARCHAR(32),currency_code VARCHAR(3),supplier_id BIGINT,reconciliation_statement_id BIGINT,updated_by BIGINT)"); + jdbc.execute("CREATE TABLE fin_settlement_line(id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,fee_source_id BIGINT,amount DECIMAL(18,2),currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,customer_id BIGINT,direction VARCHAR(32),currency_code VARCHAR(3),amount DECIMAL(18,2),review_status VARCHAR(32),status VARCHAR(32),settlement_mode VARCHAR(32),source_snapshot VARCHAR(500))"); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(32),currency_code VARCHAR(3),total_amount DECIMAL(18,2),received_amount DECIMAL(18,2))"); + jdbc.execute("CREATE TABLE fin_bill_line(id BIGINT PRIMARY KEY,tenant_id BIGINT,bill_id BIGINT,source_id BIGINT,amount DECIMAL(18,2),currency_code VARCHAR(3))"); + jdbc.update("INSERT INTO fin_settlement VALUES (88,10,100,0,100,'UNPAID','VOIDED',3,'PAYABLE','CNY',20,NULL,7),(99,10,100,0,100,'UNPAID','VOIDED',3,'PAYABLE','CNY',20,NULL,7)"); + jdbc.update("INSERT INTO fin_settlement_line VALUES (1,10,88,31,100,'CNY'),(2,10,99,31,100,'CNY')"); + jdbc.update("INSERT INTO fin_fee_source VALUES (31,10,'PAYABLE','CNY',100,'APPROVED','AVAILABLE','ON_ACCOUNT','{}')"); + jdbc.update("INSERT INTO fin_bill VALUES (501,10,'ISSUED','CNY',100,0)"); + jdbc.update("INSERT INTO fin_bill_line VALUES (1,10,501,31,100,'CNY')"); + var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource)); + configuration.addMapper(FinanceSettlementStateMapper.class); + return new Fixture(jdbc, new SqlSessionFactoryBuilder().build(configuration)); + } + + private record Fixture(JdbcTemplate jdbc, SqlSessionFactory factory) { } +} -- 2.54.0 From ed6a173b8678a43d260b89b8d10a4d2e0e0f99af Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 27 Oct 2026 15:30:28 +0800 Subject: [PATCH 17/70] fix(finance): preserve voided receivables and return current revisions --- deploy/sql/README.md | 2 + ...ce-receivable-active-uniqueness-repair.sql | 11 ++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 3 +- .../ReconciliationStatementService.java | 7 ++- finance/src/main/resources/db/finance.sql | 3 +- .../FinanceSettlementSchemaContractTest.java | 53 +++++++++++++++++++ .../FinanceSqlCommentContractTest.java | 2 +- .../ReconciliationStatementBehaviorTest.java | 25 +++++++++ 8 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 deploy/sql/finance-receivable-active-uniqueness-repair.sql diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 7e3657d50..0908be9c7 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 + `overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。 车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。 diff --git a/deploy/sql/finance-receivable-active-uniqueness-repair.sql b/deploy/sql/finance-receivable-active-uniqueness-repair.sql new file mode 100644 index 000000000..0395e5b97 --- /dev/null +++ b/deploy/sql/finance-receivable-active-uniqueness-repair.sql @@ -0,0 +1,11 @@ +-- 仅用于单独授权的 TEST:修正作废应收仍占用原对账单的唯一约束。 +-- 不更新、删除或回填业务记录;作废单的原对账单关联与修订历史保持不变。 +-- 一条原子 ALTER 同时替换约束;有效应收重复、原索引缺失或目标列已存在均应报错停止。 +-- 使用独立连接并开启遇错停止;若结果不明确,先核对结构,不盲目重放。 +-- 前置:已部署 Finance 结算重开校验(fe89b8a2b875),目标为 MySQL 8 / InnoDB。 +ALTER TABLE baiyue_finance.fin_settlement + ADD COLUMN active_receivable_statement_id BIGINT UNSIGNED + GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) + VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留', + DROP INDEX uk_fin_settlement_receivable_statement, + ADD UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id, active_receivable_statement_id); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cadcdd050..244e6525d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5044,6 +5044,7 @@ CREATE TABLE fin_settlement ( due_date date NOT NULL COMMENT '结算到期日期', payment_status varchar(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', status varchar(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废', + active_receivable_statement_id bigint unsigned GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留', version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', updated_by bigint unsigned NOT NULL COMMENT '最后修改操作人ID', @@ -5051,7 +5052,7 @@ CREATE TABLE fin_settlement ( updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_fin_settlement_no (tenant_id,settlement_no), - UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id,reconciliation_statement_id,direction), + UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id,active_receivable_statement_id), KEY idx_fin_settlement_customer_direction_status (tenant_id,direction,status), KEY idx_fin_settlement_supplier_status (supplier_id,direction,status), KEY idx_fin_settlement_receivable_statistics (direction,currency_code,generated_date,status,tenant_id), 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 e4ef8262f..ee4fc7f2e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -114,6 +114,7 @@ public class ReconciliationStatementService { return detail(request.customerId(), statementId); } + /** 推进对账单状态并保存修订,响应包含本次操作刚写入的审计记录。 */ @Transactional public ReconciliationDetail action(long operatorId, long statementId, String key, ReconciliationActionRequest request) { validateAction(statementId, request); @@ -152,9 +153,11 @@ public class ReconciliationStatementService { ReconciliationDetail after = detail(before.customerId(), statementId); audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId); complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); - return after; + // 审计在金额/状态快照之后写入,返回前重新读取,避免首个响应缺少最新修订。 + return detail(before.customerId(), statementId); } + /** 调整已出账明细并保留前后金额,响应与随后查询的修订历史一致。 */ private ReconciliationDetail adjust(long operatorId, long statementId, String key, ReconciliationActionRequest request, Bill before) { if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整"); @@ -176,7 +179,7 @@ public class ReconciliationStatementService { ReconciliationDetail after = detail(before.customerId(), statementId); audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", request.reason(), before, after, operatorId); complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId); - return after; + return detail(before.customerId(), statementId); } public ReconciliationDetail detail(long customerId, long statementId) { diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index de37e73e1..7fa15681a 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -437,6 +437,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement ( due_date DATE NOT NULL COMMENT '结算到期日期', payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废', + active_receivable_statement_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN direction='RECEIVABLE' AND status<>'VOIDED' THEN reconciliation_statement_id ELSE NULL END) VIRTUAL COMMENT '有效应收占用的对账单;作废单不占用,历史关联仍保留', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -444,7 +445,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no), - UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction), + UNIQUE KEY uk_fin_settlement_active_receivable (tenant_id, active_receivable_statement_id), KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status), KEY idx_fin_settlement_supplier_status (supplier_id, direction, status), KEY idx_fin_settlement_receivable_statistics (direction, currency_code, generated_date, status, tenant_id), diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java index 13f0ae1e8..6c38247c4 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementSchemaContractTest.java @@ -1,12 +1,20 @@ package com.baiyue.finance; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.UUID; +import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class FinanceSettlementSchemaContractTest { @@ -31,6 +39,51 @@ class FinanceSettlementSchemaContractTest { assertThat(unified).doesNotContain("ALTER TABLE fin_settlement", "assert_finance"); } + /** 执行两份真实基线的结算表定义,作废历史不占位,有效应收仍由数据库兜底唯一。 */ + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void voidedReceivableCanBeReplacedWithoutAllowingDuplicateActiveSettlement(boolean unified) throws Exception { + Path source = unified ? resolveUnifiedSql() : Path.of("src/main/resources/db/finance.sql"); + String sql = Files.readString(source, StandardCharsets.UTF_8); + var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_settlement \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql); + assertThat(table.find()).isTrue(); + // H2 仅去除 MySQL 的存储、注释和普通索引语法,保留生成列表达式及所有唯一约束。 + String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");") + .replaceAll("(?i) COMMENT '(?:[^']|'')*'", "") + .replaceAll("(?i)\\bUNSIGNED\\b|\\bVIRTUAL\\b|\\bSTORED\\b|_utf8mb4", "") + .replaceAll("(?m)^\\s*KEY [^\\r\\n]+\\r?\\n", "") + .replaceAll("UNIQUE KEY (\\w+)", "CONSTRAINT $1 UNIQUE"); + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1")); + jdbc.execute(ddl); + insertSettlement(jdbc, 1, 10, "AR-OLD", "RECEIVABLE", 501L, "VOIDED"); + insertSettlement(jdbc, 2, 10, "AR-REPLACEMENT", "RECEIVABLE", 501L, "ISSUED"); + insertSettlement(jdbc, 3, 10, "AR-HISTORY", "RECEIVABLE", 501L, "VOIDED"); + for (String status : new String[]{"ISSUED", "PARTIALLY_PAID", "PAID"}) { + jdbc.update("UPDATE fin_settlement SET status=? WHERE id=2", status); + assertThatThrownBy(() -> insertSettlement(jdbc, 4, 10, "AR-DUP", "RECEIVABLE", 501L, "ISSUED")) + .isInstanceOf(DuplicateKeyException.class); + assertThatThrownBy(() -> jdbc.update("UPDATE fin_settlement SET status='ISSUED' WHERE id=1")) + .isInstanceOf(DuplicateKeyException.class); + } + jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=2"); + assertThat(jdbc.update("UPDATE fin_settlement SET status='ISSUED' WHERE id=1")).isEqualTo(1); + insertSettlement(jdbc, 5, 11, "AR-OTHER-CUSTOMER", "RECEIVABLE", 501L, "ISSUED"); + insertSettlement(jdbc, 6, 10, "AP-ONE", "PAYABLE", null, "ISSUED"); + insertSettlement(jdbc, 7, 10, "AP-TWO", "PAYABLE", null, "ISSUED"); + assertThatThrownBy(() -> insertSettlement(jdbc, 8, 10, "AR-OLD", "RECEIVABLE", 502L, "VOIDED")) + .isInstanceOf(DuplicateKeyException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_settlement", Integer.class)).isEqualTo(6); + } + + /** 构造必填字段完整的结算行,仅改变本测试关注的客户、来源与状态。 */ + private static void insertSettlement(JdbcTemplate jdbc, long id, long customerId, String no, + String direction, Long billId, String status) { + jdbc.update("INSERT INTO fin_settlement(id,tenant_id,settlement_no,direction,customer_name_snapshot," + + "reconciliation_statement_id,total_amount,paid_amount,outstanding_amount,generated_date,due_date,status,created_by,updated_by) " + + "VALUES (?,?,?,?,'合成测试客户',?,0.03,0,0.03,CURRENT_DATE,CURRENT_DATE,?,7,7)", + id, customerId, no, direction, billId, status); + } + private static Path resolveUnifiedSql() { Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 20609d980..30795d6ec 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest { } } assertThat(tableCount).isEqualTo(23); - assertThat(columnCount).isEqualTo(352); + assertThat(columnCount).isEqualTo(353); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index 2620a8a44..e86ba0e0d 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -11,6 +11,8 @@ import com.baiyue.finance.service.FinanceDocumentNumberGenerator; import com.baiyue.finance.service.ReconciliationStatementService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.ArgumentCaptor; @@ -360,6 +362,29 @@ class ReconciliationStatementBehaviorTest { verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET total_amount="), (Object[]) any()); } + /** 首次操作响应必须带上本次审计修订,不能要求页面刷新后才看到最新操作。 */ + @ParameterizedTest + @ValueSource(strings = {"SUBMIT", "ADJUST"}) + void actionResponseIncludesRevisionWrittenByThatAction(String action) throws Exception { + boolean adjust = "ADJUST".equals(action); + JdbcTemplate jdbc = actionJdbc(adjust ? "ISSUED" : "DRAFT", adjust ? "ISSUED" : "PENDING_REVIEW", "revision-key"); + var auditWritten = new java.util.concurrent.atomic.AtomicBoolean(); + when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_audit_log"), any(Object[].class))) + .thenAnswer(invocation -> { auditWritten.set(true); return 1; }); + var revision = new com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision( + 1, action, new BigDecimal("240.00"), new BigDecimal(adjust ? "280.00" : "240.00"), + "核对即时修订", 71L, "测试财务", LocalDateTime.of(2026, 10, 27, 12, 0)); + when(jdbc.query(org.mockito.ArgumentMatchers.startsWith("SELECT action,reason"), any(RowMapper.class), eq(9L), eq(501L))) + .thenAnswer(invocation -> auditWritten.get() ? List.of(revision) : List.of()); + if (adjust) when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L))) + .thenReturn(new BigDecimal("280.00")); + var request = new ReconciliationActionRequest(action, "核对即时修订", adjust ? 3 : 0, + adjust ? List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "调整")) : List.of()); + var result = serviceWithNumber(jdbc).action(71L, 501L, "revision-key", request); + assertThat(auditWritten.get()).isTrue(); + assertThat(result.revisions()).containsExactly(revision); + } + @Test void voidIssuedStatementReleasesSourcesWhenNoReceiptOrSettlementExists() throws Exception { JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key"); -- 2.54.0 From ab457e022d0c7336559c85e309dc9d9e7258aae8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 27 Oct 2026 16:56:20 +0800 Subject: [PATCH 18/70] =?UTF-8?q?fix(firstleg):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=88=B9=E6=9C=9F=E8=B4=A7=E9=87=8F=E9=A1=BA=E5=BB=B6=E4=B8=8E?= =?UTF-8?q?=E8=AE=A2=E6=9F=9C=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/SailingCalendarDtos.java | 8 +- .../mapper/SailingCalendarDisplayMapper.java | 212 +++++++--- .../repository/SailingCalendarRepository.java | 18 +- .../service/SailingCalendarService.java | 78 ++-- .../openapi/sailing-calendar-v1.yaml | 4 +- .../SailingCalendarDisplayMapperTest.java | 363 ++++++++++++++++++ .../service/SailingCalendarServiceTest.java | 128 +++++- 7 files changed, 703 insertions(+), 108 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapperTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java index e626f65ac..3e5bd9219 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java @@ -204,13 +204,15 @@ public final class SailingCalendarDtos { int recommendedContainerCount, int pendingContainerCount, int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt, - BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {} + BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm, + BigDecimal availableBookedCapacityCbm) {} public record CalendarResult(String month, String timezone, List records, - Summary summary, FilterOptions filterOptions) { + Summary summary, FilterOptions filterOptions, LocalDateTime nextRefreshAt) { public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm, int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount, int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount, - BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {} + BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm, + BigDecimal availableBookedCapacityCbm) {} public record FilterOptions(List