From cb6c54ffe551faea69c782778da3d8438ec46ea6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 12:38:04 +0800 Subject: [PATCH] feat(fleet): integrate costs quotes operations and in-transit handovers --- .../common/event/EventDeliveryRoute.java | 2 + .../com/baiyue/common/event/EventTypes.java | 1 + .../baiyue/common/event/DeliveryAckTest.java | 4 +- .../common/web/EventDeliveryMetrics.java | 2 +- deploy/sql/README.md | 7 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 99 +- deploy/sql/海外区域统一增量-MySQL8.sql | 134 ++- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 4 + .../file/service/BusinessFileStorage.java | 2 +- .../openapi/finance-vehicle-voucher-v1.yaml | 10 + .../file/service/VehicleCarePhotoTest.java | 6 +- finance/AGENTS.md | 1 + .../fleet/InternalVehicleCostController.java | 33 + .../fleet/PlatformVehicleCostController.java | 64 ++ .../fleet/VehicleCostClientConfiguration.java | 8 + .../fleet/VehicleCostExceptionHandler.java | 11 + .../finance/fleet/VehicleCostFileClient.java | 23 + .../finance/fleet/VehicleCostModels.java | 45 + .../finance/fleet/VehicleCostService.java | 230 +++++ .../finance/fleet/VehicleCostVouchers.java | 61 ++ .../FinanceDocumentNumberGenerator.java | 2 +- finance/src/main/resources/db/finance.sql | 59 ++ .../openapi/finance-vehicle-costs-v1.yaml | 287 ++++++ .../fleet/VehicleCostAuthorizationTest.java | 67 ++ .../fleet/VehicleCostHttpContractTest.java | 23 + .../fleet/VehicleCostPersistenceTest.java | 187 ++++ .../fleet/VehicleCostVouchersTest.java | 42 + .../FinanceDocumentNumberGeneratorTest.java | 11 + .../db/fleet-finance-permissions.sql | 14 + .../main/resources/db/lastleg-permissions.sql | 22 + lastleg-service/AGENTS.md | 6 +- .../analyze-lastleg-dependencies.test.mjs | 4 +- .../lastleg/client/DirectEventClient.java | 7 + .../client/FinanceBusinessEventClient.java | 3 + .../controller/EventOperationsController.java | 2 +- .../dispatch/fleet/DispatchFleetAdapter.java | 61 ++ .../fleet/PdaFleetHandoverController.java | 22 + .../fleet/VehicleCareFinanceAdapter.java | 58 ++ .../FleetHandoverInspectionService.java | 94 ++ .../fleet/exception/FleetHandoverModels.java | 37 + .../exception/FleetHandoverPdaQuery.java | 71 ++ .../fleet/exception/FleetHandoverPolicy.java | 104 ++ .../exception/FleetHandoverRepository.java | 49 + .../fleet/exception/FleetHandoverService.java | 208 ++++ .../fleet/exception/FleetTransitEvidence.java | 10 + .../PlatformFleetHandoverController.java | 44 + .../PlatformVehicleCareFinanceController.java | 27 + .../maintenance/VehicleCareFinanceClient.java | 13 + .../VehicleCareFinanceGateway.java | 17 + .../maintenance/VehicleCareFinanceModels.java | 22 + .../VehicleCareFinanceService.java | 149 +++ .../fleet/maintenance/VehicleCareMapper.java | 4 + .../fleet/maintenance/VehicleCareService.java | 8 +- .../VehicleCareSupplierClient.java | 13 + .../fleet/quote/FleetQuoteDirectory.java | 29 + .../lastleg/fleet/quote/FleetQuoteModels.java | 32 + .../fleet/quote/FleetQuoteRepository.java | 39 + .../fleet/quote/FleetQuoteService.java | 87 ++ .../fleet/quote/FleetQuoteSupplierClient.java | 14 + .../quote/PlatformFleetQuoteController.java | 41 + .../PlatformTripOperationsController.java | 25 + ...latformTripOperationsRecordController.java | 28 + .../fleet/reporting/TripOperationsCharts.java | 32 + .../fleet/reporting/TripOperationsModels.java | 38 + .../reporting/TripOperationsRecordModels.java | 19 + .../TripOperationsRecordRepository.java | 33 + .../TripOperationsRecordService.java | 59 ++ .../reporting/TripOperationsRepository.java | 32 + .../reporting/TripOperationsService.java | 136 +++ .../fleet/trip/FleetDispatchGateway.java | 4 + .../fleet/trip/FleetTripCostBasis.java | 59 ++ .../fleet/trip/FleetTripCostModels.java | 29 + .../fleet/trip/FleetTripCostRepository.java | 93 ++ .../fleet/trip/FleetTripCostService.java | 143 +++ .../fleet/trip/FleetTripExecutionService.java | 3 +- .../trip/PlatformFleetTripCostController.java | 37 + .../fleet/trip/VehicleFulfillmentMapper.java | 12 +- .../fleet/trip/VehicleFulfillmentService.java | 6 +- .../UnloadCompletedOutboxRepository.java | 3 +- .../src/main/resources/db/lastleg.sql | 132 +++ .../resources/openapi/fleet-handovers-v1.yaml | 290 ++++++ .../openapi/fleet-operations-v1.yaml | 928 ++++++++++++++++++ .../resources/openapi/fleet-quotes-v1.yaml | 887 +++++++++++++++++ .../openapi/fleet-trip-costs-v1.yaml | 183 ++++ .../openapi/fleet-vehicle-fulfillment-v1.yaml | 4 +- .../openapi/fleet-vehicle-records-v1.yaml | 72 +- .../lastleg/client/DirectEventClientTest.java | 23 + .../fleet/VehicleCareFinanceAdapterTest.java | 48 + .../exception/FleetHandoverContractTest.java | 61 ++ .../exception/FleetHandoverPdaQueryTest.java | 74 ++ .../exception/FleetHandoverPolicyTest.java | 98 ++ .../FleetHandoverTransactionTest.java | 171 ++++ .../exception/FleetTransitEvidenceTest.java | 116 +++ .../VehicleCareFinancePersistenceTest.java | 125 +++ .../VehicleCarePersistenceTest.java | 6 +- .../fleet/quote/FleetQuoteDirectoryTest.java | 26 + .../quote/FleetQuotePersistenceTest.java | 68 ++ .../TripOperationsRecordPersistenceTest.java | 56 ++ .../reporting/TripOperationsServiceTest.java | 70 ++ .../fleet/trip/FleetTripCostContractTest.java | 77 ++ .../trip/FleetTripCostPersistenceTest.java | 189 ++++ .../fleet/trip/FleetTripPersistenceTest.java | 3 + .../trip/VehicleFulfillmentQueryTest.java | 40 +- .../repository/OutboxLeaseDatabaseTest.java | 12 +- .../lastleg-slice-dependencies.json | 5 + .../initialization-baseline-contract.test.mjs | 54 +- 107 files changed, 7304 insertions(+), 42 deletions(-) create mode 100644 file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java create mode 100644 finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java create mode 100644 finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java create mode 100644 iam/src/main/resources/db/fleet-finance-permissions.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java index 17d68e3a6..485e1563d 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java @@ -17,6 +17,8 @@ public record EventDeliveryRoute(String serviceName, String path, boolean accept new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false); case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> new EventDeliveryRoute("finance-service", "/internal/finance/v1/business-fee-source-events", false); + case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false); default -> throw new IllegalArgumentException("Unsupported direct-delivery event type: " + eventType); }; } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index 81829864f..cb25e5b9b 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -16,6 +16,7 @@ public final class EventTypes { public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted"; public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; + public static final String COMPANY_VEHICLE_COST_SUBMITTED = "CompanyVehicleCostSubmitted"; public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java index b681a6a47..1961163a1 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -29,7 +29,7 @@ class DeliveryAckTest { @Test void staleCannotAcknowledgeWalletCommandsResultsOrIndependentFees() { for (String type : new String[]{EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED, - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.COMPANY_VEHICLE_COST_SUBMITTED}) { var event = event(type); assertThatThrownBy(() -> DeliveryAck.of(event, DeliveryAck.Outcome.STALE).requireMatches(event)) .isInstanceOf(IllegalArgumentException.class); @@ -39,6 +39,8 @@ class DeliveryAckTest { @Test void mixedLastlegOutboxRoutesWalletToFinanceAndUnloadingToFirstleg() { + assertThat(EventDeliveryRoute.forEventType(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED)) + .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false)); assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_REQUESTED)) .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false)); assertThat(EventDeliveryRoute.forEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED).serviceName()).isEqualTo("firstleg-service"); diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java index 188b63a79..1a293ed2b 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java @@ -17,7 +17,7 @@ public final class EventDeliveryMetrics { FIRSTLEG("firstleg", "fl_inbound_outbox", "publish_status", "PROCESSING", List.of("FirstLegLoadingPlanCompleted", "FirstLegContainerShippingNodeRecorded")), LASTLEG("lastleg", "ll_event_outbox", "status", "PROCESSING", - List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested")), + List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested", "CompanyVehicleCostSubmitted")), FEE("lastleg", "ll_event_outbox", "status", "PROCESSING", List.of("BusinessFeeSourceChanged")), FINANCE("finance", "fin_wallet_event_outbox", "status", "SENDING", List.of("WalletCommandCompleted")); public final String service, table, status, processing; diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 974734e2d..d552636ee 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -6,12 +6,13 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 145 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 152 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。 +- 车辆成本、报价、出车实测及在途交接表对应Lastleg模块基线;公司车辆费用三表对应Finance模块基线。公司财务权限源为`iam/src/main/resources/db/fleet-finance-permissions.sql`,已合入国内统一种子,不以模块文件代替完整空库初始化。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -24,7 +25,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/145`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/152`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 678bca7f8..84a641170 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5073,6 +5073,65 @@ CREATE TABLE fin_subject ( UNIQUE KEY uk_fin_subject_code (subject_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台财务主体;首期仅一个有效主体'; +CREATE TABLE fin_vehicle_cost ( + id BIGINT NOT NULL COMMENT '公司费用主键', + subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体', + cost_no VARCHAR(64) NOT NULL COMMENT '公司车辆费用编号', + source_type VARCHAR(32) NOT NULL COMMENT '费用来源类型', + source_no VARCHAR(64) NOT NULL COMMENT '来源业务编号', + source_version BIGINT NOT NULL COMMENT '来源快照版本', + source_hash VARCHAR(71) NOT NULL COMMENT '来源内容摘要', + source_json JSON NOT NULL COMMENT '原币及折算不可变来源快照', + supplier_id BIGINT NOT NULL COMMENT '收款供应商标识', + supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称快照', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY公司费用金额', + paid_amount DECIMAL(17,2) NOT NULL DEFAULT 0 COMMENT 'CNY累计实际付款', + status VARCHAR(32) NOT NULL COMMENT '入账与付款状态', + record_version BIGINT NOT NULL COMMENT '并发版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_no (cost_no), + UNIQUE KEY uk_fin_vehicle_cost_source (subject_id,source_type,source_no), + KEY idx_fin_vehicle_cost_page (subject_id,updated_at,id), + CONSTRAINT fk_fin_vehicle_cost_subject FOREIGN KEY (subject_id) REFERENCES fin_subject(id), + CONSTRAINT chk_fin_vehicle_cost_amount CHECK (amount>0 AND paid_amount>=0 AND paid_amount<=amount), + CONSTRAINT chk_fin_vehicle_cost_owner CHECK (subject_id>0 AND supplier_id>0), + CONSTRAINT chk_fin_vehicle_cost_status CHECK (status IN ('PENDING','APPROVED','PARTIALLY_PAID','PAID','VOIDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用,不进入客户账务'; + +CREATE TABLE fin_vehicle_cost_revision ( + id BIGINT NOT NULL COMMENT '公司车辆费用历史主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + record_version BIGINT NOT NULL COMMENT '费用版本', + action VARCHAR(32) NOT NULL COMMENT '接收入账作废付款动作', + request_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '请求摘要', + operator_id BIGINT NOT NULL COMMENT '操作人或系统标识', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称快照', + reason VARCHAR(1000) NULL COMMENT '更正或操作说明', + snapshot_json JSON NOT NULL COMMENT '不可变费用快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_revision (cost_id,record_version), + UNIQUE KEY uk_fin_vehicle_cost_request (operator_id,action,request_key), + CONSTRAINT fk_fin_vehicle_revision_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用不可变审计'; + +CREATE TABLE fin_vehicle_cost_payment ( + id BIGINT NOT NULL COMMENT '公司车辆付款主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + payment_no VARCHAR(64) NOT NULL COMMENT '公司付款业务编号', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY实际付款金额', + payment_json JSON NOT NULL COMMENT '实际付款及凭证不可变快照', + created_at DATETIME(3) NOT NULL COMMENT '付款登记时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_payment_no (payment_no), + KEY idx_fin_vehicle_payment_cost (cost_id,id), + CONSTRAINT fk_fin_vehicle_payment_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id), + CONSTRAINT chk_fin_vehicle_payment_amount CHECK (amount>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆实际付款记录'; + CREATE TABLE fin_wallet_account ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', @@ -6709,6 +6768,44 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, 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 (5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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 (5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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 (5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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 (5107350,0,'PLATFORM','MENU',0,'platform:fleet:quote:list','车队报价','/overseas-master-data/fleet-quotes','GET',980,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107350,0,CURRENT_TIMESTAMP(3)); +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 (5107351,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:create','新增车队报价',NULL,'POST',981,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107351,0,CURRENT_TIMESTAMP(3)); +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 (5107352,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:edit','编辑车队报价',NULL,'PUT',982,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107352,0,CURRENT_TIMESTAMP(3)); +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 (5107353,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:status','启停车队报价',NULL,'PATCH',983,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107353,0,CURRENT_TIMESTAMP(3)); +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 (5107354,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:delete','删除车队报价',NULL,'DELETE',984,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107354,0,CURRENT_TIMESTAMP(3)); +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 (5107355,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:import','导入车队报价',NULL,'POST',985,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107355,0,CURRENT_TIMESTAMP(3)); +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 (5107356,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:export','导出车队报价',NULL,'GET',986,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107356,0,CURRENT_TIMESTAMP(3)); +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 (5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107360,0,CURRENT_TIMESTAMP(3)); +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 (5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3)); +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 (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3)); +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 (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3)); +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 (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107380,0,CURRENT_TIMESTAMP(3)); +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 (5107381,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:approve','公司车辆成本入账',NULL,'POST',1011,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107381,0,CURRENT_TIMESTAMP(3)); +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 (5107382,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:void','作废未付公司车辆成本',NULL,'POST',1012,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107382,0,CURRENT_TIMESTAMP(3)); +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 (5107383,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:pay','登记公司车辆付款',NULL,'POST',1013,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107383,0,CURRENT_TIMESTAMP(3)); +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 (5107384,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:upload','上传公司车辆付款凭证',NULL,'POST',1014,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107384,0,CURRENT_TIMESTAMP(3)); +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 (5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107385,0,CURRENT_TIMESTAMP(3)); +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 (5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107340,0,CURRENT_TIMESTAMP(3)); +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 (5107341,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-edit','更正车次直接成本',NULL,'PUT',971,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107341,0,CURRENT_TIMESTAMP(3)); 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 (5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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 (5107314,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cancel','取消车次',NULL,'POST',944,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107310,0,CURRENT_TIMESTAMP(3)); @@ -8158,4 +8255,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c21872057..677e05e0b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2795,6 +2795,138 @@ CREATE TABLE ll_fleet_trip_dispatch ( CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +CREATE TABLE ll_fleet_trip_operations_record ( + id BIGINT NOT NULL COMMENT '出车实测历史主键', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增', + details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照', + reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键', + request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_operations_version (trip_no,record_version), + UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录'; + +CREATE TABLE ll_fleet_trip_handover ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号', + request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本', + handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间', + snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照', + vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果', + driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果', + created_by VARCHAR(64) NOT NULL COMMENT '记录操作人', + created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_handover_no (handover_no), + UNIQUE KEY uk_fleet_handover_request (trip_id, request_key), + UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version), + KEY idx_fleet_handover_page (trip_id, id), + CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接'; + +CREATE TABLE ll_fleet_finance_source ( + id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键', + record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键', + source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增', + record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本', + operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀', + event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID', + payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version), + UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key), + UNIQUE KEY uk_fleet_finance_source_event (event_id), + CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0) +) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定'; + +CREATE TABLE ll_fleet_quote ( + id BIGINT PRIMARY KEY COMMENT '报价记录主键', + quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号', + kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售', + party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识', + party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照', + origin_city VARCHAR(128) NOT NULL COMMENT '发货城市', + destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码', + valid_from DATE NOT NULL COMMENT '报价生效日期', + valid_to DATE NOT NULL COMMENT '报价失效日期含当天', + status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态', + natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要', + details_json JSON NOT NULL COMMENT '完整报价明细快照', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + UNIQUE KEY uk_fleet_quote_no (quote_no), + UNIQUE KEY uk_fleet_quote_natural (natural_key), + UNIQUE KEY uk_fleet_quote_create (created_by,create_key), + KEY idx_fleet_quote_search (kind,status,valid_from,valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; + +CREATE TABLE ll_fleet_quote_history ( + id BIGINT PRIMARY KEY COMMENT '报价历史主键', + quote_id BIGINT NOT NULL COMMENT '报价记录标识', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + action VARCHAR(24) NOT NULL COMMENT '报价操作类型', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + snapshot_json JSON NOT NULL COMMENT '报价完整版本快照', + UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; + +CREATE TABLE ll_fleet_trip_cost ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号', + category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间', + remark VARCHAR(1000) NULL COMMENT '费用备注', + record_version BIGINT NOT NULL COMMENT '成本记录版本', + request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_no(cost_no), + UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key), + KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id), + CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0), + CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')), + CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款'; + +CREATE TABLE ll_fleet_trip_cost_history ( + id BIGINT NOT NULL COMMENT '内部主键', + cost_id BIGINT NOT NULL COMMENT '成本记录内部引用', + record_version BIGINT NOT NULL COMMENT '成本更正后版本', + action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + reason VARCHAR(1000) NULL COMMENT '登记或更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version), + CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史'; + CREATE TABLE ll_fleet_trip_history ( id BIGINT NOT NULL COMMENT '内部主键', trip_id BIGINT NOT NULL COMMENT '车次内部引用', @@ -3251,4 +3383,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 145 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 152 AS initialized_tables; diff --git a/file/AGENTS.md b/file/AGENTS.md index 9b30f0dd8..75e2f76ea 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -16,6 +16,7 @@ | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | +| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 | | 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 | 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 bc8bd6d68..10a375cfd 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -17,6 +17,7 @@ enum BusinessFileCategory { CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FINANCE_VEHICLE_VOUCHER(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -61,6 +62,9 @@ enum BusinessFileCategory { } void validateOwner(long tenantId) { + if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); + } if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台"); } diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index f7ac07c88..11ee988ae 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -65,7 +65,7 @@ public class BusinessFileStorage { extension = ".xlsx"; } else { if (formatPolicy == BusinessFileCategory.FormatPolicy.IMAGE && !java.util.Set.of("image/jpeg", "image/png").contains(type)) - throw bad("运维照片仅支持JPG、PNG"); + throw bad("图片附件仅支持JPG、PNG"); if (formatPolicy == BusinessFileCategory.FormatPolicy.LICENSE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type)) throw bad("驾驶证附件仅支持PDF、JPG、PNG"); extension = TYPES.get(type); diff --git a/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml b/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml new file mode 100644 index 000000000..8f6989db4 --- /dev/null +++ b/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml @@ -0,0 +1,10 @@ +openapi: 3.0.3 +info: + title: 公司车辆付款凭证归属 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController内部上传、元数据及内容接口,不增加公网路径。 + businessType为FINANCE_VEHICLE_VOUCHER,tenantId必须为0,businessId为Finance公司费用内部主键。 + 仅非空JPG/PNG,最多10MiB,同时校验MIME及文件签名;File按平台、业务类型、费用ID及文件ID复合校验。 + Finance校验list/upload/pay权限及费用归属;上传不是付款,不复用客户FINANCE_VOUCHER或车辆维修照片。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java index fb314beeb..cafa7fd4d 100644 --- a/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java +++ b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java @@ -9,8 +9,10 @@ import static org.assertj.core.api.Assertions.*; class VehicleCarePhotoTest { @TempDir Path root; - @Test void recordPhotosArePlatformOwnedAndRequireRealImageBytes() throws Exception { - var category = BusinessFileCategory.require("FLEET_VEHICLE_RECORD_PHOTO"); + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"FLEET_VEHICLE_RECORD_PHOTO", "FINANCE_VEHICLE_VOUCHER"}) + void recordPhotosAndCompanyVouchersArePlatformOwnedAndRequireRealImageBytes(String type) throws Exception { + var category = BusinessFileCategory.require(type); category.validateOwner(0); assertThatThrownBy(() -> category.validateOwner(7)).hasMessageContaining("平台"); assertThatThrownBy(() -> category.validateOwner(-1)).hasMessageContaining("平台"); diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 36a1a97b9..c59e2fdb1 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -14,6 +14,7 @@ | 目标 | 首选入口 | |---|---| | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | +| 公司车辆成本 | `fleet/`及`src/main/resources/openapi/finance-vehicle-costs-v1.yaml`;独立平台主体费用、入账和付款,不进入客户账务。编号VC/VP,凭证FINANCE_VEHICLE_VOUCHER;维修/救援由Lastleg完整快照事件接入 | | 平台财务凭证上传 | `PlatformFinanceController.java` 的 `POST /vouchers`、`client/FinanceFileClient.java`;文件类型固定 `FINANCE_VOUCHER`,业务 ID/客户范围使用当前客户 ID | | 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` | | 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` | diff --git a/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java b/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java new file mode 100644 index 000000000..4e93d58ea --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java @@ -0,0 +1,33 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.service.FinanceEventException; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@RestController +@RequestMapping("/internal/finance/v1/vehicle-costs") +public class InternalVehicleCostController { + private final VehicleCostService service; + private final String token; + public InternalVehicleCostController(VehicleCostService service, @Value("${baiyue.security.internal-service-token:}") String token) { + this.service = service; this.token = token; + } + @PostMapping("/events") + public ApiResponse receive(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied, + @RequestBody EventEnvelope event) { authorize(supplied); return ApiResponse.success(service.receive(event)); } + @PostMapping("/sources/query") + public ApiResponse> sources(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied, + @Valid @RequestBody VehicleCostModels.SourceQuery request) { authorize(supplied); return ApiResponse.success(service.sources(request)); } + private void authorize(String supplied) { + if (token == null || token.isBlank() || supplied == null + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), supplied.getBytes(StandardCharsets.UTF_8))) + throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java b/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java new file mode 100644 index 000000000..00c2aaa2f --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java @@ -0,0 +1,64 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +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.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/platform/finance/v1/vehicle-costs") +public class PlatformVehicleCostController { + private final VehicleCostService costs; + private final VehicleCostVouchers vouchers; + public PlatformVehicleCostController(VehicleCostService costs, VehicleCostVouchers vouchers) { this.costs = costs; this.vouchers = vouchers; } + @GetMapping + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> list(@Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.list(query)); } + @GetMapping("/{costNo}") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse detail(@PathVariable String costNo) { return ApiResponse.success(costs.detail(costNo)); } + @GetMapping("/{costNo}/history") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> history(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.history(costNo, query)); } + @GetMapping("/{costNo}/payments") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> payments(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.payments(costNo, query)); } + @PostMapping("/{costNo}/approve") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:approve')") + public ApiResponse approve(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) { + return ApiResponse.success(costs.approve(costNo, user.userId(), name(user), key, request)); + } + @PostMapping("/{costNo}/void") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:void')") + public ApiResponse voidCost(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) { + return ApiResponse.success(costs.voidCost(costNo, user.userId(), name(user), key, request)); + } + @PostMapping("/{costNo}/payments") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:pay')") + public ApiResponse pay(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Payment request) { + return ApiResponse.success(costs.pay(costNo, user.userId(), name(user), key, request)); + } + @PostMapping(value="/{costNo}/vouchers", consumes="multipart/form-data") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:upload')") + public ApiResponse upload(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(vouchers.upload(costs.voucherOwner(costNo), user.userId(), file)); + } + @GetMapping("/{costNo}/vouchers/{fileId}/content") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ResponseEntity download(@PathVariable String costNo, @PathVariable long fileId) { + return vouchers.download(costs.voucherOwner(costNo), fileId); + } + private static String name(AuthenticatedUser user) { + if (user.displayName() != null && !user.displayName().isBlank()) return user.displayName(); + return user.username() == null || user.username().isBlank() ? "—" : user.username(); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java new file mode 100644 index 000000000..5efb62c2e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.finance.fleet; + +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods=false) +@EnableFeignClients(clients=VehicleCostFileClient.class) +public class VehicleCostClientConfiguration { } diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java new file mode 100644 index 000000000..a1ea497b6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java @@ -0,0 +1,11 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.controller.FinanceExceptionHandler; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** Keep Finance business errors and diagnostic identifiers on the dedicated fleet endpoints. */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages="com.baiyue.finance.fleet") +public class VehicleCostExceptionHandler extends FinanceExceptionHandler { } diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java new file mode 100644 index 000000000..341b47120 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; + +@FeignClient(name="file-service", contextId="financeVehicleCostFiles", url="${baiyue.clients.file.url:}", + path="/internal/file/v1/business-files/0/FINANCE_VEHICLE_VOUCHER") +public interface VehicleCostFileClient { + @PostMapping(value="/{costId}", consumes="multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long costId, @RequestPart("file") MultipartFile file); + @GetMapping("/{costId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long costId, @PathVariable long fileId); + @GetMapping("/{costId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long costId, @PathVariable long fileId); + record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java new file mode 100644 index 000000000..226ae849c --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java @@ -0,0 +1,45 @@ +package com.baiyue.finance.fleet; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** Company-owned costs are deliberately separate from customer fee sources and settlements. */ +public final class VehicleCostModels { + private VehicleCostModels() { } + public static final String EVENT_TYPE = "CompanyVehicleCostSubmitted"; + public record Source(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType, + @NotBlank @Size(max=64) String sourceNo, @Positive long sourceVersion, + @NotBlank @Pattern(regexp="[1-9][0-9]*") String vehicleId, + @NotBlank @Size(max=64) String vehiclePlate, @Size(max=64) String tripNo, + @Positive long supplierId, @NotBlank @Size(max=200) String feeName, + @NotNull LocalDate businessDate, @NotBlank @Pattern(regexp="CNY|USD") String originalCurrency, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=12,fraction=2) BigDecimal originalAmount, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=9,fraction=6) BigDecimal exchangeRate, + @NotBlank @Pattern(regexp="[0-9]{4}-[0-9]{2}") String exchangeRateMonth, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount, + @Size(max=1000) String remark) { } + public record Command(@NotNull @PositiveOrZero Long expectedVersion, @Size(max=1000) String reason) { } + public record Payment(@NotNull @PositiveOrZero Long expectedVersion, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount, + @NotNull Instant paidAt, @NotBlank @Pattern(regexp="ONLINE|BANK_TRANSFER|CASH|OTHER") String method, + @Size(max=128) String referenceNo, + @NotEmpty @Size(max=20) List<@NotBlank @Pattern(regexp="[1-9][0-9]*") String> voucherFileIds, + @Size(max=1000) String remark) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record SourceKey(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType, + @NotBlank @Size(max=64) String sourceNo) { } + public record SourceQuery(@NotEmpty @Size(max=100) List<@Valid SourceKey> sources) { } + public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, + String status, BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { } + public record History(long recordVersion, String action, String reason, String operatorName, + Instant occurredAt, Cost snapshot) { } + public record Paid(String paymentNo, String currency, BigDecimal amount, Instant paidAt, String method, String referenceNo, + List voucherFileIds, String operatorName, Instant recordedAt, String remark) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java new file mode 100644 index 000000000..5e6ecaa6e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java @@ -0,0 +1,230 @@ +package com.baiyue.finance.fleet; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.*; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validator; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.*; +import java.util.*; + +/** Only this company-cost ledger is written. Customer costs, settlements and wallets are untouched. */ +@Service +public class VehicleCostService { + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final Validator validator; + private final FinanceSupplierValidator suppliers; + private final FinanceDocumentNumberGenerator numbers; + private final VehicleCostVouchers vouchers; + private record Row(long id, long subjectId, VehicleCostModels.Cost cost) { } + private final RowMapper rowMapper = (r, ignored) -> { + var source = read(r.getString("source_json"), VehicleCostModels.Source.class); + var paid = r.getBigDecimal("paid_amount"); + var status = r.getString("status"); + var outstanding = Set.of("APPROVED", "PARTIALLY_PAID", "PAID").contains(status) ? source.amount().subtract(paid) : null; + return new Row(r.getLong("id"), r.getLong("subject_id"), new VehicleCostModels.Cost(r.getString("cost_no"), + "PLATFORM_DEFAULT", source, r.getString("supplier_name"), "CNY", status, paid, outstanding, r.getLong("record_version"))); + }; + public VehicleCostService(JdbcTemplate jdbc, ObjectMapper json, Validator validator, + FinanceSupplierValidator suppliers, FinanceDocumentNumberGenerator numbers, VehicleCostVouchers vouchers) { + this.jdbc = jdbc; this.json = json; this.validator = validator; + this.suppliers = suppliers; this.numbers = numbers; this.vouchers = vouchers; + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public DeliveryAck receive(EventEnvelope event) { + try { event.requireValid(); } catch (RuntimeException error) { throw bad("公司车辆费用事件信封无效"); } + if (!VehicleCostModels.EVENT_TYPE.equals(event.eventType()) || event.eventVersion() != 1 + || !"0".equals(event.tenantId()) || !"COMPANY_VEHICLE_COST".equals(event.aggregateType()) + || !EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) + throw bad("公司车辆费用事件类型、归属或摘要无效"); + if (event.payload().has("customerId")) throw bad("公司车辆费用不得携带客户归属"); + var source = read(event.payload().toString(), VehicleCostModels.Source.class); + validate(source); + if (!event.aggregateId().equals(source.sourceType() + ":" + source.sourceNo()) || event.aggregateVersion() != source.sourceVersion()) + throw bad("费用来源与事件聚合版本不一致"); + long subject = subject(true); + var priorEvent = jdbc.queryForList("SELECT payload_hash,status FROM fin_inbox_event WHERE source_system='LASTLEG' AND event_id=? FOR UPDATE", event.eventId()); + if (!priorEvent.isEmpty()) { + if (!event.payloadHash().equals(priorEvent.get(0).get("payload_hash"))) throw conflict("同一事件对应不同公司费用内容"); + if (!"PROCESSED".equals(priorEvent.get(0).get("status"))) throw conflict("费用事件尚未处理完成"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); + } + String sourceHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(source))); + var current = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND source_type=? AND source_no=? FOR UPDATE", + rowMapper, subject, source.sourceType(), source.sourceNo()); + Row row; + if (!current.isEmpty()) { + row = current.get(0); + long version = row.cost().source().sourceVersion(); + if (source.sourceVersion() < version) throw conflict("费用来源版本已过期"); + if (source.sourceVersion() == version) { + var storedHash = jdbc.queryForObject("SELECT source_hash FROM fin_vehicle_cost WHERE id=?", String.class, row.id()); + if (!sourceHash.equals(storedHash)) throw conflict("同一费用来源版本对应不同内容"); + inbox(event, row.id()); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); + } + if (!Set.of("PENDING", "VOIDED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0) + throw conflict("已入账费用须先作废;已付款费用不能替换"); + String supplier = suppliers.requireActive(source.supplierId(), null).name(); + jdbc.update("UPDATE fin_vehicle_cost SET source_version=?,source_hash=?,source_json=?,supplier_id=?,supplier_name=?,amount=?,status='PENDING',record_version=record_version+1,updated_at=? WHERE id=?", + source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), row.id()); + row = required(row.cost().costNo(), false); + } else { + String supplier = suppliers.requireActive(source.supplierId(), null).name(); + long id = IdWorker.getId(); + String no = numbers.next("VC"); + jdbc.update("INSERT INTO fin_vehicle_cost(id,subject_id,cost_no,source_type,source_no,source_version,source_hash,source_json,supplier_id,supplier_name,amount,paid_amount,status,record_version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,0,'PENDING',0,?,?)", + id, subject, no, source.sourceType(), source.sourceNo(), source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), now()); + row = required(no, false); + } + audit(row, "RECEIVE", event.eventId(), event.payloadHash(), 0, "系统", "接收公司车辆费用来源"); + inbox(event, row.id()); + return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); + } + + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult list(VehicleCostModels.Query query) { + validate(query); long subject = subject(false); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost WHERE subject_id=?", Long.class, subject); + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? ORDER BY updated_at DESC,id DESC LIMIT ? OFFSET ?", + rowMapper, subject, query.size(), query.offset()); + return new PageResult<>(rows.stream().map(Row::cost).toList(), query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + @Transactional(readOnly=true) + public VehicleCostModels.Cost detail(String no) { return required(no, false).cost(); } + @Transactional(readOnly=true) + public List sources(VehicleCostModels.SourceQuery query) { + validate(query); long subject = subject(false); + String predicates = String.join(" OR ", Collections.nCopies(query.sources().size(), "(source_type=? AND source_no=?)")); + var args = new ArrayList(); args.add(subject); + for (var key : query.sources()) { args.add(key.sourceType()); args.add(key.sourceNo()); } + return jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND (" + predicates + ")", rowMapper, args.toArray()).stream().map(Row::cost).toList(); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult history(String no, VehicleCostModels.Query query) { + validate(query); long id = required(no, false).id(); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_revision WHERE cost_id=?", Long.class, id); + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost_revision WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?", + (r, ignored) -> new VehicleCostModels.History(r.getLong("record_version"), r.getString("action"), r.getString("reason"), + r.getString("operator_name"), r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + read(r.getString("snapshot_json"), VehicleCostModels.Cost.class)), id, query.size(), query.offset()); + return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult payments(String no, VehicleCostModels.Query query) { + validate(query); long id = required(no, false).id(); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_payment WHERE cost_id=?", Long.class, id); + var rows = jdbc.query("SELECT payment_json FROM fin_vehicle_cost_payment WHERE cost_id=? ORDER BY id DESC LIMIT ? OFFSET ?", + (r, ignored) -> read(r.getString("payment_json"), VehicleCostModels.Paid.class), id, query.size(), query.offset()); + return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost approve(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) { + return transition(no, operator, operatorName, key, command, "APPROVE"); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost voidCost(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) { + return transition(no, operator, operatorName, key, command, "VOID"); + } + private VehicleCostModels.Cost transition(String no, long operator, String name, String key, VehicleCostModels.Command command, String action) { + validate(command); subject(true); var row = required(no, true); + String hash = commandHash(no, command); if (replay(row, operator, action, key, hash)) return row.cost(); + requireVersion(row, command.expectedVersion()); + if (action.equals("APPROVE")) { + if (!"PENDING".equals(row.cost().status())) throw conflict("仅待入账费用可以入账"); + suppliers.requireActive(row.cost().source().supplierId(), null); + } else if (!Set.of("PENDING", "APPROVED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0) { + throw conflict("已付款或已作废费用不能直接作废"); + } + jdbc.update("UPDATE fin_vehicle_cost SET status=?,record_version=record_version+1,updated_at=? WHERE id=?", + action.equals("APPROVE") ? "APPROVED" : "VOIDED", now(), row.id()); + var changed = required(no, false); + audit(changed, action, key, hash, operator, name, text(command.reason(), action.equals("APPROVE") ? "确认公司车辆费用入账" : "作废公司车辆费用")); + return changed.cost(); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost pay(String no, long operator, String name, String key, VehicleCostModels.Payment request) { + validate(request); subject(true); var row = required(no, true); + String hash = commandHash(no, request); if (replay(row, operator, "PAY", key, hash)) return row.cost(); + requireVersion(row, request.expectedVersion()); + if (!Set.of("APPROVED", "PARTIALLY_PAID").contains(row.cost().status())) throw conflict("仅已入账且未付清费用可以登记付款"); + if (request.paidAt().isAfter(Instant.now())) throw bad("实际付款时间不能晚于当前时间"); + if (request.amount().compareTo(row.cost().outstandingAmount()) > 0) throw conflict("付款金额不得超过未付余额"); + suppliers.requireActive(row.cost().source().supplierId(), null); + vouchers.validate(row.id(), request.voucherFileIds()); + var payment = new VehicleCostModels.Paid(numbers.next("VP"), "CNY", request.amount(), request.paidAt(), request.method(), + request.referenceNo(), List.copyOf(request.voucherFileIds()), text(name, "—"), Instant.now(), request.remark()); + jdbc.update("INSERT INTO fin_vehicle_cost_payment(id,cost_id,payment_no,amount,payment_json,created_at) VALUES(?,?,?,?,?,?)", + IdWorker.getId(), row.id(), payment.paymentNo(), request.amount(), write(payment), now()); + var paid = row.cost().paidAmount().add(request.amount()); + jdbc.update("UPDATE fin_vehicle_cost SET paid_amount=?,status=?,record_version=record_version+1,updated_at=? WHERE id=?", + paid, paid.compareTo(row.cost().source().amount()) == 0 ? "PAID" : "PARTIALLY_PAID", now(), row.id()); + var changed = required(no, false); + audit(changed, "PAY", key, hash, operator, name, text(request.remark(), "登记公司车辆费用实际付款")); + return changed.cost(); + } + public long voucherOwner(String no) { return required(no, false).id(); } + + private void validate(Object value) { + if (value == null || !validator.validate(value).isEmpty()) throw bad("公司车辆费用参数不完整或格式无效"); + if (value instanceof VehicleCostModels.Source source) { + if ("TRIP_DIRECT".equals(source.sourceType()) && (source.tripNo() == null || source.tripNo().isBlank())) + throw bad("车次直接成本须保留真实车次号"); + if (!source.exchangeRateMonth().equals(source.businessDate().toString().substring(0, 7))) + throw bad("费用业务日期或汇率月份无效"); + if ("CNY".equals(source.originalCurrency()) && source.exchangeRate().compareTo(BigDecimal.ONE) != 0) + throw bad("CNY费用汇率必须为1"); + if (source.originalAmount().multiply(source.exchangeRate()).setScale(2, RoundingMode.HALF_UP).compareTo(source.amount()) != 0) + throw bad("公司车辆费用原币及折算金额不守恒"); + } + } + private long subject(boolean lock) { + var ids = jdbc.queryForList("SELECT id FROM fin_subject WHERE subject_code='PLATFORM_DEFAULT' AND status='ACTIVE'" + (lock ? " FOR UPDATE" : ""), Long.class); + if (ids.size() != 1) throw conflict("平台财务主体不存在或未启用"); + return ids.get(0); + } + private Row required(String no, boolean lock) { + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND cost_no=?" + (lock ? " FOR UPDATE" : ""), rowMapper, subject(false), no); + if (rows.isEmpty()) throw new FinanceEventException("FIN_404", "公司车辆费用不存在"); + return rows.get(0); + } + private boolean replay(Row row, long operator, String action, String key, String hash) { + if (operator <= 0 || key == null || key.isBlank() || key.length() > 128) throw bad("缺少有效操作人或幂等键"); + var rows = jdbc.queryForList("SELECT cost_id,request_hash FROM fin_vehicle_cost_revision WHERE operator_id=? AND action=? AND request_key=?", + operator, action, key); + if (rows.isEmpty()) return false; + if (((Number) rows.get(0).get("cost_id")).longValue() != row.id() || !hash.equals(rows.get(0).get("request_hash"))) + throw conflict("幂等键已用于其他财务操作内容"); + return true; + } + private void audit(Row row, String action, String key, String hash, long operator, String name, String reason) { + jdbc.update("INSERT INTO fin_vehicle_cost_revision(id,cost_id,record_version,action,request_key,request_hash,operator_id,operator_name,reason,snapshot_json,occurred_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(), row.id(), row.cost().recordVersion(), action, key, hash, operator, text(name, "—"), reason, write(row.cost()), now()); + } + private void inbox(EventEnvelope event, long id) { + jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status,result_resource_id,processed_at) VALUES(?,'LASTLEG',?,?,?,'PROCESSED',?,?)", + event.eventId(), event.eventType(), write(event), event.payloadHash(), id, now()); + } + private static void requireVersion(Row row, long version) { if (row.cost().recordVersion() != version) throw conflict("公司车辆费用版本已变化"); } + private String commandHash(String no, Object request) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(Map.of("costNo", no, "request", request)))); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("公司费用快照无法保存", error); } } + private T read(String value, Class type) { try { return json.readerFor(type).with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(value); } catch (JsonProcessingException error) { throw bad("公司费用快照格式无效"); } } + private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } + private static String text(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); } + private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); } + private static FinanceEventException conflict(String message) { return new FinanceEventException("FIN_409", message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java new file mode 100644 index 000000000..e447e45a8 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java @@ -0,0 +1,61 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.service.FinanceEventException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +public class VehicleCostVouchers { + private final VehicleCostFileClient files; + private final String token; + public VehicleCostVouchers(VehicleCostFileClient files, @Value("${baiyue.security.internal-service-token:}") String token) { + this.files = files; this.token = token; + } + public void validate(long costId, List ids) { + if (ids == null || ids.isEmpty() || ids.size() > 20 || new HashSet<>(ids).size() != ids.size()) throw bad("付款凭证不能为空或重复"); + for (var id : ids) metadata(costId, fileId(id)); + } + public VehicleCostFileClient.Metadata upload(long costId, long operator, MultipartFile file) { + requireToken(); + var response = call(() -> files.upload(token, operator, costId, file)); + if (response == null || !response.successful()) throw unavailable(); + return requireOwner(costId, response.data(), null); + } + public VehicleCostFileClient.Metadata metadata(long costId, long fileId) { + requireToken(); + var response = call(() -> files.metadata(token, costId, fileId)); + if (response == null || !response.successful()) throw unavailable(); + return requireOwner(costId, response.data(), fileId); + } + public ResponseEntity download(long costId, long fileId) { + var metadata = metadata(costId, fileId); + var response = call(() -> files.download(token, costId, fileId)); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null + || response.getBody().length != metadata.sizeBytes() || response.getHeaders().getContentType() == null + || !metadata.contentType().equalsIgnoreCase(response.getHeaders().getContentType().toString())) throw unavailable(); + return response; + } + private static VehicleCostFileClient.Metadata requireOwner(long costId, VehicleCostFileClient.Metadata value, Long expected) { + if (value == null || value.fileId() == null || value.fileId() <= 0 || expected != null && !expected.equals(value.fileId()) + || !"PLATFORM".equals(value.ownerScope()) || !Long.valueOf(0).equals(value.tenantId()) + || !"FINANCE_VEHICLE_VOUCHER".equals(value.businessType()) || !Long.toString(costId).equals(value.businessId()) + || value.contentType() == null || !Set.of("image/jpeg", "image/png").contains(value.contentType()) || value.sizeBytes() == null || value.sizeBytes() <= 0) + throw bad("公司车辆付款凭证不存在或归属不匹配"); + return value; + } + private static long fileId(String value) { + try { long id = Long.parseLong(value); if (id > 0) return id; } catch (RuntimeException ignored) { } + throw bad("付款凭证编号无效"); + } + private void requireToken() { if (token == null || token.isBlank()) throw unavailable(); } + private static T call(java.util.function.Supplier request) { + try { return request.get(); } catch (feign.FeignException unavailable) { throw unavailable(); } + } + private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); } + private static FinanceEventException unavailable() { return new FinanceEventException("FIN_503", "公司车辆付款凭证服务不可用"); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java index d9c02ac8a..d9557200c 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -17,7 +17,7 @@ import java.util.Set; public class FinanceDocumentNumberGenerator { private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC"); + private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP"); private static final long MAX_DAILY_SEQUENCE = 9_999L; private final JdbcTemplate jdbc; diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 4d0480d87..9c443bc26 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -94,6 +94,65 @@ VALUES ON DUPLICATE KEY UPDATE subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0; +CREATE TABLE fin_vehicle_cost ( + id BIGINT NOT NULL COMMENT '公司费用主键', + subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体', + cost_no VARCHAR(64) NOT NULL COMMENT '公司车辆费用编号', + source_type VARCHAR(32) NOT NULL COMMENT '费用来源类型', + source_no VARCHAR(64) NOT NULL COMMENT '来源业务编号', + source_version BIGINT NOT NULL COMMENT '来源快照版本', + source_hash VARCHAR(71) NOT NULL COMMENT '来源内容摘要', + source_json JSON NOT NULL COMMENT '原币及折算不可变来源快照', + supplier_id BIGINT NOT NULL COMMENT '收款供应商标识', + supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称快照', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY公司费用金额', + paid_amount DECIMAL(17,2) NOT NULL DEFAULT 0 COMMENT 'CNY累计实际付款', + status VARCHAR(32) NOT NULL COMMENT '入账与付款状态', + record_version BIGINT NOT NULL COMMENT '并发版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_no (cost_no), + UNIQUE KEY uk_fin_vehicle_cost_source (subject_id,source_type,source_no), + KEY idx_fin_vehicle_cost_page (subject_id,updated_at,id), + CONSTRAINT fk_fin_vehicle_cost_subject FOREIGN KEY (subject_id) REFERENCES fin_subject(id), + CONSTRAINT chk_fin_vehicle_cost_amount CHECK (amount>0 AND paid_amount>=0 AND paid_amount<=amount), + CONSTRAINT chk_fin_vehicle_cost_owner CHECK (subject_id>0 AND supplier_id>0), + CONSTRAINT chk_fin_vehicle_cost_status CHECK (status IN ('PENDING','APPROVED','PARTIALLY_PAID','PAID','VOIDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用,不进入客户账务'; + +CREATE TABLE fin_vehicle_cost_revision ( + id BIGINT NOT NULL COMMENT '公司车辆费用历史主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + record_version BIGINT NOT NULL COMMENT '费用版本', + action VARCHAR(32) NOT NULL COMMENT '接收入账作废付款动作', + request_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '请求摘要', + operator_id BIGINT NOT NULL COMMENT '操作人或系统标识', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称快照', + reason VARCHAR(1000) NULL COMMENT '更正或操作说明', + snapshot_json JSON NOT NULL COMMENT '不可变费用快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_revision (cost_id,record_version), + UNIQUE KEY uk_fin_vehicle_cost_request (operator_id,action,request_key), + CONSTRAINT fk_fin_vehicle_revision_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用不可变审计'; + +CREATE TABLE fin_vehicle_cost_payment ( + id BIGINT NOT NULL COMMENT '公司车辆付款主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + payment_no VARCHAR(64) NOT NULL COMMENT '公司付款业务编号', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY实际付款金额', + payment_json JSON NOT NULL COMMENT '实际付款及凭证不可变快照', + created_at DATETIME(3) NOT NULL COMMENT '付款登记时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_payment_no (payment_no), + KEY idx_fin_vehicle_payment_cost (cost_id,id), + CONSTRAINT fk_fin_vehicle_payment_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id), + CONSTRAINT chk_fin_vehicle_payment_amount CHECK (amount>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆实际付款记录'; + CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml b/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml new file mode 100644 index 000000000..c971dd7eb --- /dev/null +++ b/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml @@ -0,0 +1,287 @@ +openapi: 3.0.3 +info: + title: 公司车辆运营成本 + version: 1.0.0 + description: >- + 公司费用归平台财务主体PLATFORM_DEFAULT,不进入客户费用池、客户账单、客户应付单或客户钱包。 + 来源接收只生成PENDING;独立入账后才产生应付,付款只登记有凭证的实际CNY支付。 + 原币支持CNY/USD,来源提供业务月份有效汇率与不可变折算快照;Finance复核金额守恒。 + 未付款可作废;已付款禁止直接改写、作废或替换。更高来源版本仅在PENDING/VOIDED且未付款时接收。 +security: + - bearerAuth: [] +x-integration-required: + status: 源码合同,仍待共享集成与总控编译验证,不代表可部署 + numbers: FinanceDocumentNumberGenerator登记VC公司费用与VP付款业务号前缀 + file: BusinessFileCategory登记FINANCE_VEHICLE_VOUCHER;平台0、费用ID复合归属、非空JPG/PNG、10MiB、签名校验 + event: CompanyVehicleCostSubmitted v1;LASTLEG直投内部事件入口;aggregateType=COMPANY_VEHICLE_COST,aggregateId=sourceType:sourceNo,aggregateVersion=sourceVersion;平台信封tenantId为字符串0,不携带customerId + delivery: 注册EventTypes/EventDeliveryRoute及Lastleg Relay;只有匹配eventId/payloadHash的APPLIED或DUPLICATE代表接收完成,不能显示为已入账/已付款 + lastleg: 提交时锁真实来源并冻结版本、供应商、金额、月份汇率;映射与Outbox同事务提交;禁止把维修照片或reportedCost自动解释为付款证据 + sql: + fin_vehicle_cost: + columns: id BIGINT PK, subject_id BIGINT, cost_no VARCHAR(64), source_type VARCHAR(32), source_no VARCHAR(64), source_version BIGINT, source_hash VARCHAR(71), source_json JSON, supplier_id BIGINT, supplier_name VARCHAR(200), amount DECIMAL(17,2), paid_amount DECIMAL(17,2), status VARCHAR(32), record_version BIGINT, created_at DATETIME(3), updated_at DATETIME(3) + unique: [cost_no, 'subject_id + source_type + source_no'] + checks: 正数主体和供应商;amount>0;0<=paid_amount<=amount;状态PENDING/APPROVED/PARTIALLY_PAID/PAID/VOIDED;无customer_id/tenant_id + indexes: [subject_id + updated_at + id] + fin_vehicle_cost_revision: + columns: id BIGINT PK, cost_id BIGINT, record_version BIGINT, action VARCHAR(32), request_key VARCHAR(128), request_hash VARCHAR(71), operator_id BIGINT, operator_name VARCHAR(128), reason VARCHAR(1000), snapshot_json JSON, occurred_at DATETIME(3) + unique: ['cost_id + record_version', 'operator_id + action + request_key'] + description: RECEIVE/APPROVE/VOID/PAY不可变审计及幂等记录,和主记录及付款同事务提交 + fin_vehicle_cost_payment: + columns: id BIGINT PK, cost_id BIGINT, payment_no VARCHAR(64), amount DECIMAL(17,2), payment_json JSON, created_at DATETIME(3) + unique: [payment_no] + indexes: [cost_id + id] + description: 不可变实际付款凭证快照;amount>0;付款与余额、审计同事务 +paths: + /api/platform/finance/v1/vehicle-costs: + get: + description: platform:finance:vehicle-cost:list;只查询公司成本 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + /api/platform/finance/v1/vehicle-costs/{costNo}: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/history: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list;不可变版本与当时金额快照 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + /api/platform/finance/v1/vehicle-costs/{costNo}/approve: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:approve;PENDING入账为APPROVED,复核供应商;不是付款 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: {$ref: '#/components/requestBodies/Command'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/void: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:void;仅未付款PENDING/APPROVED可作废;更高来源版本可重新待入账 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: {$ref: '#/components/requestBodies/Command'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/payments: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list;CNY付款历史 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + post: + description: platform:finance:vehicle-cost:pay;仅APPROVED/PARTIALLY_PAID,付款金额不超过未付余额,必须有本费用的公司付款凭证 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Payment'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/vouchers: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:upload;上传不是付款,不生成付款流水 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': {description: ApiResponse,data为本费用专属凭证元数据} + /api/platform/finance/v1/vehicle-costs/{costNo}/vouchers/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/CostNo'} + - {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + get: + description: platform:finance:vehicle-cost:list;校验平台、文件类别和费用归属 + responses: + '200': {description: 图片字节及File响应头} + /internal/finance/v1/vehicle-costs/events: + post: + security: [{internalService: []}] + description: 内部身份;同eventId异载荷拒绝,同来源版本异内容拒绝,旧来源版本拒绝;业务与Inbox同事务提交后ACK + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [eventId, eventType, eventVersion, occurredAt, tenantId, aggregateType, aggregateId, aggregateVersion, traceId, correlationId, payload, payloadHash] + properties: + eventId: {type: string, format: uuid} + eventType: {type: string, enum: [CompanyVehicleCostSubmitted]} + eventVersion: {type: integer, enum: [1]} + occurredAt: {type: string, format: date-time} + tenantId: {type: string, enum: ['0']} + aggregateType: {type: string, enum: [COMPANY_VEHICLE_COST]} + aggregateId: {type: string} + aggregateVersion: {type: integer, minimum: 1} + traceId: {type: string} + correlationId: {type: string} + payloadHash: {type: string} + payload: {$ref: '#/components/schemas/Source'} + responses: + '200': {description: 'ApiResponse;eventId、payloadHash与请求一致,outcome为APPLIED/DUPLICATE;仅表示接收完成'} + /internal/finance/v1/vehicle-costs/sources/query: + post: + security: [{internalService: []}] + description: 最多100组来源批量查询,未接收来源不返回;来源不存在不能解释为已付款或零成本 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sources] + properties: + sources: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [sourceType, sourceNo] + properties: + sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]} + sourceNo: {type: string, maxLength: 64} + responses: + '200': {description: 'ApiResponse>'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + internalService: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + CostNo: {name: costNo, in: path, required: true, schema: {type: string}} + Page: {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + Size: {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + Key: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + requestBodies: + Command: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, format: int64, minimum: 0} + reason: {type: string, maxLength: 1000} + responses: + Cost: + description: 业务响应;FIN_400参数/归属、FIN_404不存在、FIN_409状态/版本/幂等冲突 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: {$ref: '#/components/schemas/Cost'} + Page: + description: ApiResponse;data含records/page/size/total/pages,列表Cost、历史History或付款Paid + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + type: object + properties: + page: {type: integer, format: int64} + size: {type: integer, format: int64} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + records: + type: array + items: + oneOf: + - {$ref: '#/components/schemas/Cost'} + - {$ref: '#/components/schemas/History'} + - {$ref: '#/components/schemas/Paid'} + schemas: + Envelope: + type: object + properties: + code: {type: string} + message: {type: string} + requestId: {type: string} + traceId: {type: string} + data: {type: object} + Source: + type: object + additionalProperties: false + required: [sourceType, sourceNo, sourceVersion, vehicleId, vehiclePlate, supplierId, feeName, businessDate, originalCurrency, originalAmount, exchangeRate, exchangeRateMonth, amount] + properties: + sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]} + sourceNo: {type: string, maxLength: 64} + sourceVersion: {type: integer, format: int64, minimum: 1} + vehicleId: {type: string, pattern: '^[1-9][0-9]*$'} + vehiclePlate: {type: string, maxLength: 64} + tripNo: {type: string, nullable: true, maxLength: 64, description: TRIP_DIRECT必须提供真实车次号} + supplierId: {type: integer, format: int64, minimum: 1} + feeName: {type: string, maxLength: 200} + businessDate: {type: string, format: date} + originalCurrency: {type: string, enum: [CNY, USD]} + originalAmount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01} + exchangeRate: {type: number, minimum: 0, exclusiveMinimum: true, description: CNY必须为1;USD来自业务月份有效汇率} + exchangeRateMonth: {type: string, pattern: '^[0-9]{4}-[0-9]{2}$'} + amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY折算金额,与原币乘汇率四舍五入到分严格核平} + remark: {type: string, nullable: true, maxLength: 1000} + Cost: + type: object + properties: + costNo: {type: string} + subjectCode: {type: string, enum: [PLATFORM_DEFAULT]} + source: {$ref: '#/components/schemas/Source'} + supplierName: {type: string} + currency: {type: string, enum: [CNY]} + status: {type: string, enum: [PENDING, APPROVED, PARTIALLY_PAID, PAID, VOIDED]} + paidAmount: {type: number} + outstandingAmount: {type: number, nullable: true, description: 未入账或已作废时为空,不能当已知零应付} + recordVersion: {type: integer, format: int64} + Payment: + type: object + required: [expectedVersion, amount, paidAt, method, voucherFileIds] + properties: + expectedVersion: {type: integer, format: int64, minimum: 0} + amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY实际付款} + paidAt: {type: string, format: date-time} + method: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER]} + referenceNo: {type: string, maxLength: 128} + voucherFileIds: {type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: {type: string, pattern: '^[1-9][0-9]*$'}} + remark: {type: string, maxLength: 1000} + History: + type: object + properties: + recordVersion: {type: integer, format: int64} + action: {type: string, enum: [RECEIVE, APPROVE, VOID, PAY]} + reason: {type: string} + operatorName: {type: string} + occurredAt: {type: string, format: date-time} + snapshot: {$ref: '#/components/schemas/Cost'} + Paid: + type: object + properties: + paymentNo: {type: string} + currency: {type: string, enum: [CNY]} + amount: {type: number} + paidAt: {type: string, format: date-time} + method: {type: string} + referenceNo: {type: string, nullable: true} + voucherFileIds: {type: array, items: {type: string}} + operatorName: {type: string} + recordedAt: {type: string, format: date-time} + remark: {type: string, nullable: true} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java new file mode 100644 index 000000000..8183ac460 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java @@ -0,0 +1,67 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCostAuthorizationTest { + @Test void actualMethodSecurityDoesNotLetReadersApprovePayOrVoid() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformVehicleCostController.class); + var service = context.getBean(VehicleCostService.class); + var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L); when(user.displayName()).thenReturn("财务人员"); + var request = new VehicleCostModels.Command(0L, null); + login("platform:finance:vehicle-cost:list"); controller.detail("VC-ONE"); + assertThatThrownBy(() -> controller.approve("VC-ONE", user, "approve-one", request)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:approve"); controller.approve("VC-ONE", user, "approve-one", request); + assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:pay"); controller.pay("VC-ONE", user, "pay-one", null); + assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:void"); controller.voidCost("VC-ONE", user, "void-one", request); + login(); assertThatThrownBy(() -> controller.detail("VC-ONE")).isInstanceOf(AccessDeniedException.class); + verify(service).detail("VC-ONE"); verify(service).approve("VC-ONE", 8L, "财务人员", "approve-one", request); + verify(service).pay("VC-ONE", 8L, "财务人员", "pay-one", null); + verify(service).voidCost("VC-ONE", 8L, "财务人员", "void-one", request); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + private static void login(String... permissions) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused", + java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); + } + @Test void companyCostActionsHaveIndependentPermissionsAndReadsUseListPermission() throws Exception { + assertThat(PlatformVehicleCostController.class.getMethod("detail", String.class).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:finance:vehicle-cost:list')"); + for (var entry : java.util.Map.of("approve", "approve", "voidCost", "void").entrySet()) { + assertThat(PlatformVehicleCostController.class.getMethod(entry.getKey(), String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Command.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:" + entry.getValue() + "')"); + } + assertThat(PlatformVehicleCostController.class.getMethod("pay", String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Payment.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:pay')"); + } + @Test void internalEndpointsRejectMissingOrWrongServiceIdentityBeforeServiceCalls() { + var service = mock(VehicleCostService.class); + var controller = new InternalVehicleCostController(service, "test-internal-token"); + assertThatThrownBy(() -> controller.receive(null, null)).hasMessageContaining("令牌"); + assertThatThrownBy(() -> controller.sources("wrong", null)).hasMessageContaining("令牌"); + assertThatThrownBy(() -> new InternalVehicleCostController(service, "").receive("", null)).hasMessageContaining("令牌"); + verifyNoInteractions(service); + } + @Configuration @EnableMethodSecurity static class Config { + @Bean VehicleCostService costs() { return mock(VehicleCostService.class); } + @Bean VehicleCostVouchers vouchers() { return mock(VehicleCostVouchers.class); } + @Bean PlatformVehicleCostController controller(VehicleCostService costs, VehicleCostVouchers vouchers) { + return new PlatformVehicleCostController(costs, vouchers); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java new file mode 100644 index 000000000..9b126e4c3 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.hamcrest.Matchers.*; + +class VehicleCostHttpContractTest { + @Test void fleetBusinessErrorsKeepFinanceCodesAndDiagnosticIds() throws Exception { + var service = mock(VehicleCostService.class); + when(service.detail("VC-ONE")).thenThrow(new FinanceEventException("FIN_409", "公司车辆费用版本已变化")); + var mvc = MockMvcBuilders.standaloneSetup(new PlatformVehicleCostController(service, mock(VehicleCostVouchers.class))) + .setControllerAdvice(new VehicleCostExceptionHandler()).build(); + mvc.perform(get("/api/platform/finance/v1/vehicle-costs/VC-ONE")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("FIN_409")) + .andExpect(jsonPath("$.message").value("公司车辆费用版本已变化")) + .andExpect(jsonPath("$.requestId", not(emptyOrNullString()))) + .andExpect(jsonPath("$.traceId", not(emptyOrNullString()))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java new file mode 100644 index 000000000..eb31525cd --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java @@ -0,0 +1,187 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WarehouseSupplierClient; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.validation.Validation; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class VehicleCostPersistenceTest { + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private JdbcTemplate jdbc; + private VehicleCostService service; + private FinanceSupplierValidator suppliers; + private VehicleCostVouchers vouchers; + private ValidatorFactory validators; + private static final VehicleCostModels.Query PAGE = new VehicleCostModels.Query(1, 20); + @BeforeEach void setup() { + var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fin_subject(id BIGINT PRIMARY KEY,subject_code VARCHAR(64),status VARCHAR(20))"); + jdbc.update("INSERT INTO fin_subject VALUES(1,'PLATFORM_DEFAULT','ACTIVE')"); + jdbc.execute("CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64),payload CLOB,payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT,processed_at TIMESTAMP(3),UNIQUE(source_system,event_id))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost(id BIGINT PRIMARY KEY,subject_id BIGINT NOT NULL,cost_no VARCHAR(64) NOT NULL UNIQUE,source_type VARCHAR(32) NOT NULL,source_no VARCHAR(64) NOT NULL,source_version BIGINT NOT NULL,source_hash VARCHAR(71) NOT NULL,source_json CLOB NOT NULL,supplier_id BIGINT NOT NULL,supplier_name VARCHAR(200) NOT NULL,amount DECIMAL(17,2) NOT NULL,paid_amount DECIMAL(17,2) NOT NULL,status VARCHAR(32) NOT NULL,record_version BIGINT NOT NULL,created_at TIMESTAMP(3),updated_at TIMESTAMP(3),UNIQUE(subject_id,source_type,source_no),CHECK(paid_amount>=0 AND paid_amount<=amount))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost_revision(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,record_version BIGINT NOT NULL,action VARCHAR(32) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,operator_id BIGINT NOT NULL,operator_name VARCHAR(128) NOT NULL,reason VARCHAR(1000),snapshot_json CLOB NOT NULL,occurred_at TIMESTAMP(3),UNIQUE(cost_id,record_version),UNIQUE(operator_id,action,request_key))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost_payment(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,payment_no VARCHAR(64) NOT NULL UNIQUE,amount DECIMAL(17,2) NOT NULL,payment_json CLOB NOT NULL,created_at TIMESTAMP(3))"); + suppliers = mock(FinanceSupplierValidator.class); + when(suppliers.requireActive(8L, null)).thenReturn(new WarehouseSupplierClient.SupplierSnapshot(8L, "维修供应商", null, "ACTIVE")); + vouchers = mock(VehicleCostVouchers.class); + var numbers = mock(FinanceDocumentNumberGenerator.class); var sequence = new AtomicInteger(); + when(numbers.next(anyString())).thenAnswer(call -> call.getArgument(0) + "-" + sequence.incrementAndGet()); + validators = Validation.buildDefaultValidatorFactory(); + var target = new VehicleCostService(jdbc, json, validators.getValidator(), suppliers, numbers, vouchers); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (VehicleCostService) proxy.getProxy(); + } + @AfterEach void close() { validators.close(); } + private VehicleCostModels.Source source(long version, String amount) { + return new VehicleCostModels.Source("REPAIR", "VR-ONE", version, "101", "TEST-PLATE", "TR-ONE", 8, + "车辆维修", LocalDate.of(2026, 1, 1), "USD", new BigDecimal(amount), new BigDecimal("7.000000"), "2026-01", + new BigDecimal(amount).multiply(new BigDecimal("7")).setScale(2), null); + } + private EventEnvelope event(VehicleCostModels.Source source) { + var payload = json.valueToTree(source); + return new EventEnvelope(UUID.randomUUID().toString(), VehicleCostModels.EVENT_TYPE, 1, Instant.now(), "0", + "COMPANY_VEHICLE_COST", source.sourceType() + ":" + source.sourceNo(), source.sourceVersion(), null, + UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + private VehicleCostModels.Cost receive() { service.receive(event(source(1, "10"))); return service.list(PAGE).records().get(0); } + private VehicleCostModels.Payment payment(long version, String amount) { + return new VehicleCostModels.Payment(version, new BigDecimal(amount), Instant.parse("2026-01-02T00:00:00Z"), + "BANK_TRANSFER", "PAY-REF", List.of("11"), null); + } + @Test void acceptedSourceIsNotBookedOrPaidAndReplayRequiresTheSameHash() { + var first = event(source(1, "10")); + assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE); + var other = event(source(1, "11")); + var conflict = new EventEnvelope(first.eventId(), other.eventType(), 1, other.occurredAt(), "0", other.aggregateType(), other.aggregateId(), 1, + null, other.traceId(), other.correlationId(), null, other.payload(), other.payloadHash()); + assertThatThrownBy(() -> service.receive(conflict)).hasMessageContaining("不同公司费用"); + var row = service.list(PAGE).records().get(0); + assertThat(row.status()).isEqualTo("PENDING"); assertThat(row.outstandingAmount()).isNull(); + assertThat(row.paidAmount()).isZero(); assertThat(row.source().amount()).isEqualByComparingTo("70"); + assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isEqualTo(1); + } + @Test void missingVersionCannotApproveOrPayTheInitialRecord() throws Exception { + var row = receive(); + var missing = json.readValue("{}", VehicleCostModels.Command.class); + assertThat(missing.expectedVersion()).isNull(); + assertThatThrownBy(() -> service.approve(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数"); + assertThatThrownBy(() -> service.voidCost(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数"); + var pay = new VehicleCostModels.Payment(null, BigDecimal.ONE, Instant.parse("2026-01-02T00:00:00Z"), "CASH", null, List.of("11"), null); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "missing-version", pay)).hasMessageContaining("参数"); + assertThat(service.detail(row.costNo()).status()).isEqualTo("PENDING"); + assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1); + } + @Test void failedSourceAuditCannotAcknowledgeOrPersistHalfAnEvent() { + var event = event(source(1, "10")); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_source_audit CHECK(action<>'RECEIVE')"); + assertThatThrownBy(() -> service.receive(event)).isInstanceOf(RuntimeException.class); + assertThat(service.list(PAGE).total()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero(); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision DROP CONSTRAINT reject_source_audit"); + assertThat(service.receive(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + assertThat(service.list(PAGE).total()).isEqualTo(1); + } + @Test void concurrentDuplicateEventsOnlyPersistOneSourceAndOneHistory() throws Exception { + var event = event(source(1, "10")); var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> service.receive(event)); var b = executor.submit(() -> service.receive(event)); + assertThat(List.of(a.get(10, TimeUnit.SECONDS).outcome(), b.get(10, TimeUnit.SECONDS).outcome())) + .containsExactlyInAnyOrder(DeliveryAck.Outcome.APPLIED, DeliveryAck.Outcome.DUPLICATE); + } finally { executor.shutdownNow(); } + assertThat(service.list(PAGE).total()).isEqualTo(1); + } + @Test void partialAndFullPaymentConserveAmountsAndReplayNeverPaysTwice() { + var row = receive(); var no = row.costNo(); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "before-approve", payment(0, "1"))).hasMessageContaining("已入账"); + var approved = service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + assertThat(approved.outstandingAmount()).isEqualByComparingTo("70"); + var partial = service.pay(no, 1, "财务", "payment-one", payment(1, "20")); + assertThat(partial.status()).isEqualTo("PARTIALLY_PAID"); assertThat(partial.outstandingAmount()).isEqualByComparingTo("50"); + assertThat(service.pay(no, 1, "财务", "payment-one", payment(1, "20"))).isEqualTo(partial); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "payment-one", payment(1, "21"))).hasMessageContaining("幂等键"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "too-much", payment(2, "51"))).hasMessageContaining("未付余额"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "stale-version", payment(1, "1"))).hasMessageContaining("版本"); + var paid = service.pay(no, 1, "财务", "payment-two", payment(2, "50")); + assertThat(paid.status()).isEqualTo("PAID"); assertThat(paid.outstandingAmount()).isZero(); + assertThat(service.payments(no, PAGE).total()).isEqualTo(2); + assertThatThrownBy(() -> service.voidCost(no, 1, "财务", "void-paid", new VehicleCostModels.Command(3L, null))).hasMessageContaining("已付款"); + assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("不能替换"); + } + @Test void unpaidBookedCostRequiresVoidBeforeNewVersionAndRetainsSnapshots() { + var row = receive(); var no = row.costNo(); + service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("先作废"); + service.voidCost(no, 1, "财务", "void-one", new VehicleCostModels.Command(1L, "金额重新核对")); + service.receive(event(source(2, "20"))); + var latest = service.detail(no); + assertThat(latest.status()).isEqualTo("PENDING"); assertThat(latest.source().amount()).isEqualByComparingTo("140"); + assertThat(service.history(no, PAGE).records()).hasSize(4); + assertThat(service.history(no, PAGE).records().get(3).snapshot().source().amount()).isEqualByComparingTo("70"); + assertThatThrownBy(() -> service.receive(event(source(1, "10")))).hasMessageContaining("过期"); + } + @Test void auditFailureRollsBackPaymentAndBalances() { + var row = receive(); var no = row.costNo(); + service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_payment_audit CHECK(action<>'PAY')"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "failing-payment", payment(1, "20"))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(no).paidAmount()).isZero(); assertThat(service.detail(no).recordVersion()).isEqualTo(1); + assertThat(service.payments(no, PAGE).records()).isEmpty(); + } + @Test void voucherOrSupplierFailureDoesNotCreatePayment() { + var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + doThrow(new IllegalStateException("凭证归属不符")).when(vouchers).validate(anyLong(), anyList()); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("归属"); + assertThat(service.payments(row.costNo(), PAGE).total()).isZero(); + reset(vouchers); when(suppliers.requireActive(8L, null)).thenThrow(new IllegalStateException("供应商不可用")); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("不可用"); + assertThat(service.detail(row.costNo()).paidAmount()).isZero(); + } + @Test void concurrentPaymentsCannotOverpay() throws Exception { + var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + var executor = Executors.newFixedThreadPool(2); + try { + Callable first = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-a", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } }; + Callable second = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-b", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } }; + var a = executor.submit(first); var b = executor.submit(second); + assertThat(List.of(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + } finally { executor.shutdownNow(); } + assertThat(service.detail(row.costNo()).paidAmount()).isEqualByComparingTo("50"); + assertThat(service.detail(row.costNo()).outstandingAmount()).isEqualByComparingTo("20"); + } + @Test void rejectsClientScopeBadExchangeAndBadSourceBeforeWritingAnything() { + var valid = event(source(1, "10")); + var customer = new EventEnvelope(valid.eventId(), valid.eventType(), 1, valid.occurredAt(), "12", valid.aggregateType(), valid.aggregateId(), 1, + null, valid.traceId(), valid.correlationId(), null, valid.payload(), valid.payloadHash()); + assertThatThrownBy(() -> service.receive(customer)).hasMessageContaining("归属"); + var tree = (com.fasterxml.jackson.databind.node.ObjectNode) valid.payload().deepCopy(); tree.put("amount", 71); + var invalid = new EventEnvelope(UUID.randomUUID().toString(), valid.eventType(), 1, valid.occurredAt(), "0", valid.aggregateType(), valid.aggregateId(), 1, + null, valid.traceId(), valid.correlationId(), null, tree, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(tree))); + assertThatThrownBy(() -> service.receive(invalid)).hasMessageContaining("不守恒"); + assertThat(service.list(PAGE).total()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero(); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java new file mode 100644 index 000000000..32d0406e6 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java @@ -0,0 +1,42 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCostVouchersTest { + @Test void onlyPlatformFilesBoundToThisCompanyCostCanProvePayment() { + var client = mock(VehicleCostFileClient.class); + var service = new VehicleCostVouchers(client, "test-internal-token"); + when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success( + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L))); + service.validate(5, List.of("11")); + for (var wrong : List.of( + new VehicleCostFileClient.Metadata(11L, "CUSTOMER", 12L, "FINANCE_VOUCHER", "12", "payment.png", "image/png", 100L), + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "6", "payment.png", "image/png", 100L), + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FLEET_VEHICLE_RECORD_PHOTO", "5", "repair.png", "image/png", 100L))) { + when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success(wrong)); + assertThatThrownBy(() -> service.validate(5, List.of("11"))).hasMessageContaining("归属"); + } + } + @Test void absentConfigurationOrDuplicateVouchersFailClosed() { + var client = mock(VehicleCostFileClient.class); + assertThatThrownBy(() -> new VehicleCostVouchers(client, "").validate(5, List.of("11"))).hasMessageContaining("不可用"); + assertThatThrownBy(() -> new VehicleCostVouchers(client, "test").validate(5, List.of("11", "11"))).hasMessageContaining("重复"); + verifyNoInteractions(client); + } + @Test void fileServiceBusinessErrorBodyCannotBeForwardedAsPaymentEvidence() { + var client = mock(VehicleCostFileClient.class); + var service = new VehicleCostVouchers(client, "test"); + when(client.metadata("test", 5, 11)).thenReturn(ApiResponse.success( + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L))); + when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + assertThatThrownBy(() -> service.download(5, 11)).hasMessageContaining("不可用"); + when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[100])); + assertThat(service.download(5, 11).getBody()).hasSize(100); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java index 26720351a..ba0177c12 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -56,4 +56,15 @@ class FinanceDocumentNumberGeneratorTest { .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); } + + @Test + void vehicleCostsAndPaymentsUseIndependentSequenceKeys() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L); + FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); + assertThat(generator.next("VC")).isEqualTo("VC-260818-0001"); + assertThat(generator.next("VP")).isEqualTo("VP-260818-0001"); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818")); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818")); + } } diff --git a/iam/src/main/resources/db/fleet-finance-permissions.sql b/iam/src/main/resources/db/fleet-finance-permissions.sql new file mode 100644 index 000000000..65a46f428 --- /dev/null +++ b/iam/src/main/resources/db/fleet-finance-permissions.sql @@ -0,0 +1,14 @@ +-- 公司车辆成本独立权限;仅初始化使用,不覆盖客户账务权限。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107380,0,CURRENT_TIMESTAMP(3)); +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 (5107381,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:approve','公司车辆成本入账',NULL,'POST',1011,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107381,0,CURRENT_TIMESTAMP(3)); +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 (5107382,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:void','作废未付公司车辆成本',NULL,'POST',1012,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107382,0,CURRENT_TIMESTAMP(3)); +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 (5107383,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:pay','登记公司车辆付款',NULL,'POST',1013,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107383,0,CURRENT_TIMESTAMP(3)); +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 (5107384,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:upload','上传公司车辆付款凭证',NULL,'POST',1014,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107384,0,CURRENT_TIMESTAMP(3)); diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 01fd80ecd..775750945 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -83,6 +83,20 @@ VALUES (5107316,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:release','结束车辆司机任务',NULL,'POST',946,'ACTIVE',1,0), (5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0), (5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0), + (5107350,0,'PLATFORM','MENU',0,'platform:fleet:quote:list','车队报价','/overseas-master-data/fleet-quotes','GET',980,'ACTIVE',1,0), + (5107351,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:create','新增车队报价',NULL,'POST',981,'ACTIVE',1,0), + (5107352,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:edit','编辑车队报价',NULL,'PUT',982,'ACTIVE',1,0), + (5107353,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:status','启停车队报价',NULL,'PATCH',983,'ACTIVE',1,0), + (5107354,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:delete','删除车队报价',NULL,'DELETE',984,'ACTIVE',1,0), + (5107355,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:import','导入车队报价',NULL,'POST',985,'ACTIVE',1,0), + (5107356,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:export','导出车队报价',NULL,'GET',986,'ACTIVE',1,0), + (5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,0), + (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,0), + (5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,0), + (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0), + (5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0), + (5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0), + (5107341,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-edit','更正车次直接成本',NULL,'PUT',971,'ACTIVE',1,0), (5107330,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-create','新增外接预订',NULL,'POST',960,'ACTIVE',1,0), (5107331,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-edit','修改外接预订',NULL,'PUT',961,'ACTIVE',1,0), (5107332,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-confirm','确认外接承运',NULL,'POST',962,'ACTIVE',1,0), @@ -137,6 +151,14 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit') +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + DELETE role_permission FROM iam_role_permission role_permission JOIN iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id JOIN iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 273532344..9bcf01a0b 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -75,7 +75,11 @@ ## 海外仓全流程开发路由 -- 车辆履约只读投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同 `src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml`。车辆路径下`deliveries`同时要求vehicle:list与delivery:list,`assignment-history`同时要求vehicle:list与trip:list;不新增权限码或跨库查询。计划派送归当前分配车辆,实际运输归不可变DEPART快照;客户与排车双键关联。更换读取相邻版本ASSIGN快照,首次分配不算更换,旧车/新车/仅换司机分别呈现,不按当前车牌猜历史。列表分页在只读重复读事务内核对;内部ID及整份客户快照不直接返回。 +- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 +- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。 +- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 + +- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index 5d07260b4..d1adbc7cd 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -29,7 +29,9 @@ test('fleet stays a business slice and its policies have no infrastructure depen const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); - assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)), + assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target) + || edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' + && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'), 'dispatch must use fleet ports and guards, not its persistence mappers'); const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(incomingEdges.length > 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java index 4555c23df..c396cd388 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java @@ -27,6 +27,7 @@ public class DirectEventClient { EventTypes.OVERSEAS_DELIVERY_COMPLETED -> firstleg.receive(token, event); case EventTypes.WALLET_COMMAND_REQUESTED -> checkedFinance(finance.wallet(token, event)); case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> checkedFinance(finance.fee(token, event)); + case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED -> checkedVehicleCost(finance.vehicleCost(token, event)); default -> throw new EventDeliveryFailure(true, "Unsupported outbound event type: " + event.eventType()); }; } @@ -34,4 +35,10 @@ public class DirectEventClient { EventDeliveryFailure.requireSuccessful(response, FINANCE_PERMANENT_CODES); return response; } + private ApiResponse checkedVehicleCost(ApiResponse response) { + var permanentCodes = new java.util.HashSet<>(FINANCE_PERMANENT_CODES); + permanentCodes.addAll(Set.of("FIN_400", "FIN_404", "FIN_409")); + EventDeliveryFailure.requireSuccessful(response, permanentCodes); + return response; + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java index 96e35de55..797987c52 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java @@ -12,6 +12,9 @@ import org.springframework.web.bind.annotation.RequestHeader; @FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "finance-service", contextId = "FinanceBusinessEventClient", url = "${FINANCE_SERVICE_URL:${baiyue.clients.finance.url:}}") public interface FinanceBusinessEventClient { + @PostMapping("/internal/finance/v1/vehicle-costs/events") + ApiResponse vehicleCost(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); @PostMapping("/internal/finance/v1/wallet-command-events") ApiResponse wallet(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody EventEnvelope envelope); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java index bf1ab9909..08ea643a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java @@ -24,7 +24,7 @@ public class EventOperationsController { ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { guard = new EventOperationsTokenGuard(token); operations = new OutboxOperations(jdbc, transactions, - new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)), + new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.COMPANY_VEHICLE_COST_SUBMITTED)), json -> { try { EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index ad328d63c..0c5a97102 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -174,6 +174,67 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason); } // Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone. + @Override + public com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock) { + var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); + var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) + : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); + if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus()) + || dispatch.getShippedPackageCount() == null || dispatch.getShippedPackageCount() <= 0 + || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) + throw conflict("交接必须具有已完成出库的真实发运依据"); + var rows = lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) + : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); + var facts = new java.util.ArrayList(); + long packages = 0; + for (var row : rows) { + if (!Objects.equals(row.getCustomerId(), dispatch.getCustomerId()) + || !Objects.equals(row.getDispatchOrderId(), dispatch.getId()) + || !Objects.equals(row.getOutboundOrderId(), outbound.getId()) + || !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId()) + || row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null + || row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus())) + throw conflict("派送货物归属或执行状态与原始发运不一致"); + Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) + ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null; + if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null) + throw conflict("派送终态缺少真实签收或退回完成证据"); + String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null, + row.getDeliveryAddress(), row.getPostalCode()); + if (key == null) throw conflict("派送单缺少真实卸货目的地"); + var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(), + dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal); + facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(), + new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone()))); + packages = Math.addExact(packages, row.getPackageCount()); + } + if (facts.isEmpty() || packages != dispatch.getShippedPackageCount()) + throw conflict("派送明细未核平本次实发货物"); + return new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence(dispatchNo, dispatch.getCustomerId(), + dispatch.getOverseasWarehouseId(), dispatch.getShippedPackageCount(), java.util.List.copyOf(facts)); + } + + @Override + public void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment) { + var current = transitEvidence(evidence.dispatchNo(), true); + if (!current.equals(evidence)) throw conflict("交接货物已变化,请重新核对"); + for (var fact : evidence.deliveries()) { + if (fact.cargo().terminalAt() != null) continue; + var row = deliveries.lockByNo(evidence.customerId(), fact.cargo().deliveryNo()); + if (row == null || !Objects.equals(row.getVersion(), fact.recordVersion()) || row.getSignedAt() != null) + throw conflict("派送货物已变化,请重新核对交接"); + if (deliveries.update(null, new LambdaUpdateWrapper() + .eq(DeliveryOrder::getId, row.getId()).eq(DeliveryOrder::getCustomerId, evidence.customerId()) + .eq(DeliveryOrder::getVersion, fact.recordVersion()).eq(DeliveryOrder::getStatus, fact.status()) + .isNull(DeliveryOrder::getSignedAt) + .set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName()) + .set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, fact.recordVersion() + 1) + .set(DeliveryOrder::getUpdatedAt, LocalDateTime.now())) != 1) + throw conflict("派送交接保存失败,请刷新核对"); + } + } + private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); } private static boolean originallySigned(DeliveryOrder order) { return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java new file mode 100644 index 000000000..6d10de600 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/dispatches") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") +public class PdaFleetHandoverController { + private final FleetHandoverPdaQuery service; + public PdaFleetHandoverController(FleetHandoverPdaQuery service) { this.service = service; } + @GetMapping("/{dispatchNo}/handover-status") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { + var identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.status(identity.overseasWarehouseId(), dispatchNo)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java new file mode 100644 index 000000000..ce32c0452 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.financefee.fleet; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +@Component +public class VehicleCareFinanceAdapter implements VehicleCareFinanceGateway { + private final OverseasFeeCurrencyConverter converter; + private final LastlegEventOutboxMapper outbox; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + public VehicleCareFinanceAdapter(OverseasFeeCurrencyConverter converter, LastlegEventOutboxMapper outbox, JdbcTemplate jdbc, ObjectMapper json) { + this.converter = converter; this.outbox = outbox; this.jdbc = jdbc; this.json = json; + } + @Override + public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) { + var converted = converter.convert(currency, amount, businessDate); + return new Conversion(converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount()); + } + @Override @Transactional(propagation=Propagation.MANDATORY) + public void publish(EventEnvelope envelope) { + envelope.requireValid(); + if (!EventTypes.COMPANY_VEHICLE_COST_SUBMITTED.equals(envelope.eventType()) || !"0".equals(envelope.tenantId())) + throw new IllegalArgumentException("车辆公司成本Outbox事件类型或平台归属无效"); + var event = new LastlegEventOutbox(); event.setId(IdWorker.getId()); event.setCustomerId("0"); + event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); + event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(envelope.correlationId()); + try { event.setPayloadJson(json.writeValueAsString(envelope)); } + catch (JsonProcessingException error) { throw new IllegalStateException("车辆公司成本事件无法保存", error); } + event.setStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + if (outbox.insert(event) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "财务投递记录保存失败"); + } + @Override @Transactional(readOnly=true) + public Map deliveryStates(List keys) { + if (keys.size() > 100) throw new IllegalArgumentException("车辆成本投递查询最多100个事件"); + if (keys.isEmpty()) return Map.of(); + var result = new HashMap(); + jdbc.query("SELECT event_key,status FROM ll_event_outbox WHERE event_key IN (" + String.join(",", Collections.nCopies(keys.size(), "?")) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("event_key"), r.getString("status")), keys.toArray()); + return Map.copyOf(result); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java new file mode 100644 index 000000000..243a9ffb7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.*; + +/** Internal preparation for the handover transaction. No stock, fee or departure command is called. */ +@Service +public class FleetHandoverInspectionService { + private final FleetTripMapper trips; + private final FleetTripHistoryMapper history; + private final FleetTripDispatchMapper bindings; + private final FleetDispatchGateway dispatches; + private final ObjectMapper json; + + public FleetHandoverInspectionService(FleetTripMapper trips, FleetTripHistoryMapper history, + FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, ObjectMapper json) { + this.trips = trips; this.history = history; this.bindings = bindings; this.dispatches = dispatches; this.json = json; + } + + public record Prepared(long recordVersion, FleetHandoverPolicy.Inspection cargo, + List executionEvidence) { } + + /** Caller holds a transaction; command preparation locks trip -> customer dispatch -> outbound -> deliveries. */ + @Transactional(propagation = Propagation.MANDATORY) + public Prepared prepare(String tripNo, Instant handedOverAt, Load replacementCapacity, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null || trip.getCompletionJson() != null) + throw conflict("仅已发车且尚未结案的车次允许在途交接"); + var departures = history.selectList(new LambdaQueryWrapper() + .eq(FleetTripHistory::getTripId, trip.getId()).eq(FleetTripHistory::getAction, "DEPART")); + if (departures.size() != 1) throw conflict("车次缺少唯一原始发车快照"); + FleetTripModels.AuditSnapshot original; + try { original = json.readValue(departures.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class); } + catch (JsonProcessingException error) { throw new IllegalStateException("原始发车快照无法读取", error); } + if (original.trip() == null || !tripNo.equals(original.trip().tripNo()) + || !trip.getWarehouseId().toString().equals(original.trip().warehouseId()) + || !trip.getDepartedAt().toInstant(ZoneOffset.UTC).equals(original.trip().departedAt()) + || original.confirmedLoad() == null || original.confirmedLoad().cargo() == null + || original.confirmedLoad().pallets() == null || original.dispatches() == null) + throw conflict("原始发车快照不完整或与车次不一致"); + Map departedBindings = new TreeMap<>(); + for (var binding : original.dispatches()) { + if (departedBindings.putIfAbsent(binding.dispatchNo(), binding) != null) + throw conflict("原始发车快照包含重复客户排车"); + } + var current = bindings.listByTrip(trip.getId()); + if (current.size() != departedBindings.size()) throw conflict("车次成员与原始发车不一致"); + Set seen = new HashSet<>(); + for (var binding : current) { + var originalBinding = departedBindings.get(binding.getDispatchNo()); + if (!seen.add(binding.getDispatchNo()) || originalBinding == null + || !Objects.equals(originalBinding.customerId(), binding.getCustomerId()) + || !Objects.equals(originalBinding.packageCount(), binding.getPackageCount())) + throw conflict("客户排车归属或箱数与原始发车不一致"); + } + List evidence = new ArrayList<>(); + for (var binding : departedBindings.values()) { + var fact = dispatches.transitEvidence(binding.dispatchNo(), lock); + if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo()) + || !Objects.equals(binding.customerId(), fact.customerId()) + || !trip.getWarehouseId().toString().equals(fact.warehouseId()) + || binding.packageCount() != fact.shippedPackages()) + throw conflict("实际发运与车次客户、仓库或箱数不一致"); + evidence.add(fact); + } + var load = original.confirmedLoad(); + var cargo = load.cargo().stream().map(row -> new FleetHandoverPolicy.Cargo(row.customerId(), row.dispatchNo(), + row.dispatchItemId(), row.warehousePalletId(), row.destinationKey(), row.packages())).toList(); + var measured = load.pallets().stream().map(row -> new FleetHandoverPolicy.Pallet(row.warehousePalletId(), + new Load(row.palletSpaces(), row.weightKg(), row.volumeCbm()))).toList(); + try { + var inspection = FleetHandoverPolicy.inspect(original.trip().departedAt(), handedOverAt, Instant.now(), cargo, + evidence.stream().flatMap(row -> row.deliveries().stream()).map(FleetTransitEvidence.Delivery::cargo).toList(), + measured, replacementCapacity); + return new Prepared(trip.getRecordVersion(), inspection, List.copyOf(evidence)); + } catch (IllegalArgumentException invalid) { throw conflict(invalid.getMessage()); } + } + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java new file mode 100644 index 000000000..649d8856b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.Instant; +import java.util.List; + +public final class FleetHandoverModels { + private FleetHandoverModels() { } + public record Selection(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, + @NotNull @Positive Long driverId, @NotNull Instant expectedDriverEndAt, @NotNull Instant handedOverAt) { } + public record Confirm(@NotNull @Valid Selection selection, @NotBlank @Pattern(regexp="[0-9a-f]{64}") String cargoHash, + @NotBlank @Size(max=500) String location, @Size(max=1000) String reason, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Release(@NotNull @Min(0) Long recordVersion, + @NotBlank @Pattern(regexp="VEHICLE|DRIVER") String resourceKind, + @NotNull Instant actualEndAt, @NotBlank @Size(max=500) String location, + @Pattern(regexp="IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Resource(String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone) { } + public record Preview(String tripNo, long recordVersion, String cargoHash, Resource previous, Resource replacement, + Load capacity, FleetHandoverPolicy.Inspection cargo, DriverQualificationPolicy.Qualification driverQualification) { } + public record DeliveryReference(String customerId, String deliveryNo) { } + public record Snapshot(Resource previous, Resource replacement, Instant handedOverAt, String location, String reason, + Instant expectedDriverEndAt, Load capacity, FleetHandoverPolicy.Inspection cargo, + List executionEvidence, List carriedDeliveries, String operatorId, Instant recordedAt) { } + public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String location, + String vehicleStatus, String operatorId, Instant recordedAt) { } + public record Response(String handoverNo, String tripNo, long recordVersion, Snapshot snapshot, + Released vehicleRelease, Released driverRelease) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + public long offset() { return (page - 1L) * size; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java new file mode 100644 index 000000000..2c6cdd9af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */ +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class FleetHandoverPdaQuery { + private final FleetTripDispatchMapper bindings; + private final FleetTripMapper trips; + private final FleetTripHistoryMapper history; + private final FleetDispatchGateway dispatches; + private final FleetHandoverRepository handovers; + private final ObjectMapper json; + public FleetHandoverPdaQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, FleetTripHistoryMapper history, + FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) { + this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json; + } + public record Delivery(String deliveryNo, String status, int packages, Instant terminalAt, FleetTransitEvidence.Transport transport) { } + public record Handover(String handoverNo, Instant handedOverAt, String location, + FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { } + public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion, + FleetTransitEvidence.Transport originalDeparture, List deliveries, Handover latestHandover) { } + + public Status status(long warehouseId, String dispatchNo) { + var binding = bindings.findByDispatchNo(dispatchNo); + if (binding == null) throw notFound(); + var trip = trips.findByNo(binding.getTripNo()); + // Check token-bound warehouse before reading any customer's transport or historical data. + if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound(); + if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); + var evidence = dispatches.transitEvidence(dispatchNo, false); + if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId()) + || !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); + var original = history.selectList(new LambdaQueryWrapper().eq(FleetTripHistory::getTripId, trip.getId()) + .eq(FleetTripHistory::getAction, "DEPART")); + if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录"); + try { + var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip(); + if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound(); + var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone()); + var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), + row.cargo().terminalAt(), row.transport())).toList(); + var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo))); + Handover handover = null; + if (latest != null) { + var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class); + handover = new Handover(latest.handoverNo(), snapshot.handedOverAt(), snapshot.location(), + transport(snapshot.previous()), transport(snapshot.replacement())); + } + return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover); + } catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); } + } + private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) { + return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone()); + } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前仓库没有该排车交接记录"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java new file mode 100644 index 000000000..8be21f895 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import java.time.Instant; +import java.util.*; + +/** Pure inspection of server-owned departure and delivery facts; never changes stock or fees. */ +public final class FleetHandoverPolicy { + private FleetHandoverPolicy() { } + + public record Cargo(String customerId, String dispatchNo, String itemId, String palletId, + String destinationKey, int packages) { } + /** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */ + public record Delivery(String customerId, String dispatchNo, String deliveryNo, + String destinationKey, int packages, Instant terminalAt) { } + public record Pallet(String palletId, Load measuredLoad) { } + public record Inspection(List remainingCargo, List completedDeliveries, + Set remainingPalletIds, Load occupied) { } + private record Destination(String customerId, String dispatchNo, String key) { } + private record Item(String customerId, String dispatchNo, String itemId) { } + private record DeliveryKey(String customerId, String deliveryNo) { } + + /** + * Caller locks trip, dispatches and deliveries and derives terminalAt from trusted execution facts. + * An unresolved refusal has no terminalAt and remains aboard. A signed delivery later returned + * uses its original signature. This policy does not invent per-box partial-signature facts. + */ + public static Inspection inspect(Instant departedAt, Instant handedOverAt, Instant now, + List departureCargo, List deliveries, + List departureMeasurements, Load replacementCapacity) { + if (departedAt == null || handedOverAt == null || now == null + || handedOverAt.isBefore(departedAt) || handedOverAt.isAfter(now)) + throw invalid("交接时间须在实际发车之后且不晚于当前时间"); + if (departureCargo == null || departureCargo.isEmpty() || deliveries == null || deliveries.isEmpty()) + throw invalid("缺少原始发运或派送依据"); + Map shipped = new HashMap<>(); + Map palletDestinations = new HashMap<>(); + Set itemKeys = new HashSet<>(); + for (Cargo cargo : departureCargo) { + if (cargo == null || blank(cargo.customerId()) || blank(cargo.dispatchNo()) || blank(cargo.itemId()) + || blank(cargo.palletId()) || blank(cargo.destinationKey()) || cargo.packages() <= 0) + throw invalid("原始发运缺少客户、排车、实托、目的地或箱数"); + if (!itemKeys.add(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()))) + throw invalid("原始发运货物重复"); + shipped.merge(destination(cargo), cargo.packages(), Math::addExact); + String previous = palletDestinations.putIfAbsent(cargo.palletId(), cargo.destinationKey()); + if (previous != null && !previous.equals(cargo.destinationKey())) + throw invalid("同一实托包含不同卸货目的地,请先核对原始装载"); + } + Map byDestination = new HashMap<>(); + Set deliveryKeys = new HashSet<>(); + List completed = new ArrayList<>(); + for (Delivery delivery : deliveries) { + if (delivery == null || blank(delivery.customerId()) || blank(delivery.dispatchNo()) + || blank(delivery.deliveryNo()) || blank(delivery.destinationKey()) || delivery.packages() <= 0) + throw invalid("派送事实缺少业务归属或箱数"); + var key = new Destination(delivery.customerId(), delivery.dispatchNo(), delivery.destinationKey()); + if (!deliveryKeys.add(new DeliveryKey(delivery.customerId(), delivery.deliveryNo())) + || byDestination.putIfAbsent(key, delivery) != null) + throw invalid("派送事实重复或目的地拆分尚未核平"); + if (!Objects.equals(shipped.get(key), delivery.packages())) + throw invalid("派送客户、排车、目的地或箱数与原始发运不一致"); + if (delivery.terminalAt() != null) { + if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt)) + throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对"); + completed.add(delivery); + } + } + if (!byDestination.keySet().equals(shipped.keySet())) throw invalid("派送事实未覆盖全部原始发运货物"); + Map measurements = new HashMap<>(); + if (departureMeasurements == null) throw invalid("缺少原始实测装载"); + for (Pallet pallet : departureMeasurements) { + if (pallet == null || blank(pallet.palletId()) || !positive(pallet.measuredLoad()) + || measurements.putIfAbsent(pallet.palletId(), pallet.measuredLoad()) != null) + throw invalid("原始实托测量缺失或重复"); + } + if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致"); + List remaining = departureCargo.stream() + .filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList(); + if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理"); + Set palletIds = new TreeSet<>(); + remaining.forEach(cargo -> palletIds.add(cargo.palletId())); + Load occupied = new Load(0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO); + // Keep the measured whole-pallet load when only one customer's portion has been signed. + // It is unsafe to infer residual weight/volume from package-count ratios. + for (String palletId : palletIds) occupied = occupied.plus(measurements.get(palletId)); + if (!positive(replacementCapacity)) throw invalid("请确认接替车辆托位、载重和容积"); + if (occupied.palletSpaces() > replacementCapacity.palletSpaces() + || occupied.weightKg().compareTo(replacementCapacity.weightKg()) > 0 + || occupied.volumeCbm().compareTo(replacementCapacity.volumeCbm()) > 0) + throw invalid("未完成货物超过接替车辆容量"); + return new Inspection(List.copyOf(remaining), List.copyOf(completed), + Collections.unmodifiableSet(palletIds), occupied); + } + + private static Destination destination(Cargo cargo) { + return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey()); + } + private static boolean positive(Load load) { + return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0; + } + private static boolean blank(String text) { return text == null || text.isBlank(); } + private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java new file mode 100644 index 000000000..ff2429569 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.fleet.exception; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +/** All mutations are serialized by the owning trip row lock. Stored snapshots are immutable. */ +@Repository +public class FleetHandoverRepository { + record Row(long id, long tripId, long tripVersion, String tripNo, String handoverNo, String requestKey, String requestHash, + long version, Instant handedOverAt, String snapshot, String vehicleRelease, String driverRelease) { } + private static final RowMapper ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getLong("trip_version"), + r.getString("trip_no"), r.getString("handover_no"), r.getString("request_key"), r.getString("request_hash"), + r.getLong("record_version"), r.getTimestamp("handed_over_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + r.getString("snapshot_json"), r.getString("vehicle_release_json"), r.getString("driver_release_json")); + private final JdbcTemplate jdbc; + public FleetHandoverRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + Row created(long tripId, String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND request_key=?", ROW, tripId, key).stream().findFirst().orElse(null); + } + Row find(long tripId, String number) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND handover_no=?", ROW, tripId, number).stream().findFirst().orElse(null); + } + Row latest(long tripId) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT 1", ROW, tripId).stream().findFirst().orElse(null); + } + Row latestForCargo(long tripId, String customerDispatchJson) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND JSON_CONTAINS(snapshot_json,?,'$.cargo.remainingCargo')=1 ORDER BY trip_version DESC LIMIT 1", + ROW, tripId, customerDispatchJson).stream().findFirst().orElse(null); + } + long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_handover WHERE trip_id=?", Long.class, tripId); } + List page(long tripId, FleetHandoverModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset()); + } + int insert(Row row, String operator, Instant now) { + return jdbc.update("INSERT INTO ll_fleet_trip_handover(id,trip_id,trip_version,trip_no,handover_no,request_key,request_hash,record_version,handed_over_at,snapshot_json,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + row.id(), row.tripId(), row.tripVersion(), row.tripNo(), row.handoverNo(), row.requestKey(), row.requestHash(), row.version(), + LocalDateTime.ofInstant(row.handedOverAt(), ZoneOffset.UTC), row.snapshot(), operator, LocalDateTime.ofInstant(now, ZoneOffset.UTC)); + } + int release(Row row, boolean vehicle, String result) { + String column = vehicle ? "vehicle_release_json" : "driver_release_json"; + return jdbc.update("UPDATE ll_fleet_trip_handover SET " + column + "=?,record_version=record_version+1 WHERE id=? AND record_version=? AND " + column + " IS NULL", + result, row.id(), row.version()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java new file mode 100644 index 000000000..42b59463f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java @@ -0,0 +1,208 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.driver.DriverModels; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*; + +@Service +@Transactional(isolation = Isolation.READ_COMMITTED) +public class FleetHandoverService { + private final FleetTripMapper trips; + private final FleetResourceMapper resources; + private final FleetTripService history; + private final FleetDispatchGateway dispatches; + private final VehicleRepository vehicles; + private final VehicleApplicationService vehicleService; + private final DriverService drivers; + private final FleetHandoverInspectionService inspection; + private final FleetHandoverRepository repository; + private final ObjectMapper json; + + public FleetHandoverService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripService history, + FleetDispatchGateway dispatches, VehicleRepository vehicles, VehicleApplicationService vehicleService, + DriverService drivers, FleetHandoverInspectionService inspection, FleetHandoverRepository repository, ObjectMapper json) { + this.trips = trips; this.resources = resources; this.history = history; this.dispatches = dispatches; + this.vehicles = vehicles; this.vehicleService = vehicleService; this.drivers = drivers; + this.inspection = inspection; this.repository = repository; this.json = json; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public PageResult page(String tripNo, Query query) { + var trip = required(tripNo, false); + long count = repository.count(trip.getId()); + return new PageResult<>(repository.page(trip.getId(), query).stream().map(this::response).toList(), + query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + + public Preview preview(String tripNo, Selection selection) { + var trip = required(tripNo, true); + return prepare(trip, selection).preview(); + } + + public Response confirm(String tripNo, String operator, Confirm request) { + var trip = required(tripNo, true); + String requestHash = hash(request); + var previous = repository.created(trip.getId(), request.idempotencyKey()); + if (previous != null) { + if (!previous.requestHash().equals(requestHash)) throw conflict("交接请求标识已用于不同内容"); + return response(previous); + } + var prepared = prepare(trip, request.selection()); + if (!prepared.preview().cargoHash().equals(request.cargoHash())) throw conflict("交接货物、资源或派送状态已变化,请重新核对"); + if (request.location() == null || request.location().isBlank()) throw bad("请填写实际交接地点"); + reserve("VEHICLE", request.selection().vehicleId(), trip); + reserve("DRIVER", request.selection().driverId(), trip); + var replacement = prepared.preview().replacement(); + for (var evidence : prepared.evidence()) dispatches.handover(evidence, + new FleetDispatchGateway.Assignment(replacement.vehiclePlate(), replacement.driverName(), replacement.driverPhone())); + Instant recordedAt = Instant.now(); + String reason = request.reason() == null || request.reason().isBlank() ? "在途车辆司机交接" : request.reason().trim(); + var snapshot = new Snapshot(prepared.preview().previous(), replacement, request.selection().handedOverAt(), + request.location().trim(), reason, request.selection().expectedDriverEndAt(), prepared.preview().capacity(), + prepared.preview().cargo(), prepared.evidence(), prepared.evidence().stream().flatMap(item -> item.deliveries().stream()) + .filter(item -> item.cargo().terminalAt() == null).map(item -> new DeliveryReference(item.cargo().customerId(), item.cargo().deliveryNo())).toList(), operator, recordedAt); + var row = new FleetHandoverRepository.Row(IdWorker.getId(), trip.getId(), request.selection().recordVersion() + 1, tripNo, + "HO-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT), + request.idempotencyKey(), requestHash, 0, request.selection().handedOverAt(), write(snapshot), null, null); + if (repository.insert(row, operator, recordedAt) != 1) throw conflict("交接记录保存失败"); + trip.setVehicleId(request.selection().vehicleId()); trip.setVehiclePlate(replacement.vehiclePlate()); + trip.setDriverId(request.selection().driverId()); trip.setDriverName(replacement.driverName()); trip.setDriverPhone(replacement.driverPhone()); + trip.setMaxPalletSpaces(prepared.preview().capacity().palletSpaces()); trip.setMaxLoadKg(prepared.preview().capacity().weightKg()); + trip.setMaxVolumeCbm(prepared.preview().capacity().volumeCbm()); + trip.setExpectedDriverEndAt(LocalDateTime.ofInstant(request.selection().expectedDriverEndAt(), ZoneOffset.UTC)); + // Old resources remain reserved until their own actual task ends. DEPART and load_json are never rewritten. + history.recordExecution(trip, request.selection().recordVersion(), "HANDOVER", reason, operator); + return response(row); + } + + public Response release(String tripNo, String handoverNo, String operator, Release request) { + var trip = required(tripNo, true); + var row = repository.find(trip.getId(), handoverNo); + if (row == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "交接记录不存在"); + if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务"); + boolean vehicle = "VEHICLE".equals(request.resourceKind()); + String requestHash = hash(request), previous = vehicle ? row.vehicleRelease() : row.driverRelease(); + if (previous != null) { + var released = read(previous, Released.class); + if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(requestHash)) + throw conflict("原资源任务已结束,请核对原请求"); + return response(row); + } + if (row.version() != request.recordVersion()) throw conflict("交接记录已变化,请刷新"); + var snapshot = read(row.snapshot(), Snapshot.class); + String oldId = vehicle ? snapshot.previous().vehicleId() : snapshot.previous().driverId(); + String nextId = vehicle ? snapshot.replacement().vehicleId() : snapshot.replacement().driverId(); + Long currentId = vehicle ? trip.getVehicleId() : trip.getDriverId(); + if (oldId.equals(nextId) || oldId.equals(Objects.toString(currentId, null))) + throw conflict("该资源仍承担当前运输任务,不能通过旧交接结束"); + if (request.actualEndAt() == null || request.actualEndAt().isBefore(snapshot.handedOverAt()) + || request.actualEndAt().isAfter(Instant.now()) || request.location() == null || request.location().isBlank()) + throw bad("请填写不早于交接且不晚于当前的实际结束时间和地点"); + if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus()))) + throw bad("请确认原车辆结束后的实际状态"); + if (!vehicle && request.vehicleStatus() != null) throw bad("司机任务结束不能修改车辆状态"); + long resourceId = Long.parseLong(oldId); + if (vehicle) { + var oldVehicle = vehicles.lockById(resourceId).orElseThrow(() -> conflict("原车辆档案不存在")); + releaseResource(request.resourceKind(), resourceId, trip.getId()); + vehicleService.changeStatus(resourceId, operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), oldVehicle.recordVersion())); + } else releaseResource(request.resourceKind(), resourceId, trip.getId()); + var released = new Released(request.idempotencyKey(), requestHash, request.actualEndAt(), request.location().trim(), + request.vehicleStatus(), operator, Instant.now()); + if (repository.release(row, vehicle, write(released)) != 1) throw conflict("原资源结束记录已变化"); + history.recordExecution(trip, trip.getRecordVersion(), vehicle ? "HANDOVER_RELEASE_VEHICLE" : "HANDOVER_RELEASE_DRIVER", + "结束交接前资源任务", operator); + return response(repository.find(trip.getId(), handoverNo)); + } + + private record Prepared(Preview preview, List evidence) { } + private record Basis(Selection selection, Resource previous, Resource replacement, Load capacity, + com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Qualification qualification, + FleetHandoverInspectionService.Prepared inspection) { } + private Prepared prepare(FleetTripEntity trip, Selection selection) { + if (selection.recordVersion() == null || trip.getRecordVersion().longValue() != selection.recordVersion()) + throw conflict("车次已变化,请刷新后重新核对"); + if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getVehicleId() == null || trip.getDriverId() == null) + throw conflict("仅已发车且资源完整的车次可交接"); + if (Objects.equals(selection.vehicleId(), trip.getVehicleId()) && Objects.equals(selection.driverId(), trip.getDriverId())) + throw bad("请选择需要接替的车辆或司机"); + if (selection.expectedDriverEndAt() == null || !selection.expectedDriverEndAt().isAfter(Instant.now()) + || selection.handedOverAt() == null || !selection.expectedDriverEndAt().isAfter(selection.handedOverAt())) + throw bad("司机预计任务结束须晚于交接和当前时间"); + var latest = repository.latest(trip.getId()); + if (latest != null && selection.handedOverAt().isBefore(read(latest.snapshot(), Snapshot.class).handedOverAt())) + throw bad("交接时间不能早于上次交接"); + requireOwned("VEHICLE", trip.getVehicleId(), trip.getId()); requireOwned("DRIVER", trip.getDriverId(), trip.getId()); + var vehicle = vehicles.lockById(selection.vehicleId()).orElseThrow(() -> bad("接替车辆不存在")); + if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆"); + if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null) + throw bad("请先确认接替车辆实际容量"); + DriverModels.Response driver = drivers.requireDispatchable(selection.driverId(), selection.expectedDriverEndAt()); + if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机"); + requireAvailable("VEHICLE", selection.vehicleId(), trip.getVehicleId(), trip.getId()); + requireAvailable("DRIVER", selection.driverId(), trip.getDriverId(), trip.getId()); + var capacity = new Load(vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm()); + var checked = inspection.prepare(trip.getTripNo(), selection.handedOverAt(), capacity, true); + var previous = new Resource(trip.getVehicleId().toString(), trip.getVehiclePlate(), trip.getDriverId().toString(), trip.getDriverName(), trip.getDriverPhone()); + var replacement = new Resource(Long.toString(vehicle.id()), vehicle.plateNumber(), driver.id(), driver.details().name(), driver.details().phone()); + String cargoHash = hash(new Basis(selection, previous, replacement, capacity, driver.qualification(), checked)); + return new Prepared(new Preview(trip.getTripNo(), trip.getRecordVersion(), cargoHash, previous, replacement, + capacity, checked.cargo(), driver.qualification()), checked.executionEvidence()); + } + private void requireOwned(String kind, long id, long tripId) { + var owner = resources.find(kind, id); + if (owner == null || owner.tripId() != tripId) throw conflict("当前资源占用与车次不一致"); + } + private void requireAvailable(String kind, long id, long currentId, long tripId) { + var owner = resources.find(kind, id); + if (owner != null && (id != currentId || owner.tripId() != tripId)) + throw conflict("接替资源仍有未结束任务,请先核对实际任务结束"); + } + private void reserve(String kind, long id, FleetTripEntity trip) { + long current = "VEHICLE".equals(kind) ? trip.getVehicleId() : trip.getDriverId(); + if (id == current) { requireOwned(kind, id, trip.getId()); return; } + try { if (resources.reserve(kind, id, trip.getId(), trip.getTripNo()) != 1) throw conflict("接替资源占用保存失败"); } + catch (DuplicateKeyException occupied) { throw conflict("接替资源已被占用,请重新核对"); } + } + private void releaseResource(String kind, long id, long tripId) { + if (resources.release(kind, id, tripId) != 1) throw conflict("原资源占用不一致,未结束任务"); + } + private FleetTripEntity required(String tripNo, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private Response response(FleetHandoverRepository.Row row) { + return new Response(row.handoverNo(), row.tripNo(), row.version(), read(row.snapshot(), Snapshot.class), + row.vehicleRelease() == null ? null : read(row.vehicleRelease(), Released.class), + row.driverRelease() == null ? null : read(row.driverRelease(), Released.class)); + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java new file mode 100644 index 000000000..de5162f38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.fleet.exception; + +import java.util.List; + +/** Internal port response. Identity always comes from the original customer dispatch. */ +public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId, + int shippedPackages, List deliveries) { + public record Transport(String vehiclePlate, String driverName, String driverPhone) { } + public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java new file mode 100644 index 000000000..5a14d5c46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers") +public class PlatformFleetHandoverController { + private final FleetHandoverService service; + public PlatformFleetHandoverController(FleetHandoverService service) { this.service = service; } + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> page(@PathVariable String tripNo, @Valid FleetHandoverModels.Query query) { + return ApiResponse.success(service.page(tripNo, query)); + } + @PostMapping("/preview") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse preview(@PathVariable String tripNo, @Valid @RequestBody FleetHandoverModels.Selection request) { + return ApiResponse.success(service.preview(tripNo, request)); + } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:handover')") + public ApiResponse confirm(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody FleetHandoverModels.Confirm request) { return ApiResponse.success(service.confirm(tripNo, operator(user), request)); } + @PostMapping("/{handoverNo}/resources/release") + @PreAuthorize("hasAuthority('platform:fleet:trip:release')") + public ApiResponse release(@PathVariable String tripNo, @PathVariable String handoverNo, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetHandoverModels.Release request) { + return ApiResponse.success(service.release(tripNo, handoverNo, operator(user), request)); + } + private static String operator(AuthenticatedUser user) { + if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); + return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java new file mode 100644 index 000000000..389f2c663 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records") +public class PlatformVehicleCareFinanceController { + private final VehicleCareFinanceService service; + public PlatformVehicleCareFinanceController(VehicleCareFinanceService service) { this.service = service; } + @PostMapping("/finance-status") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')") + public ApiResponse> statuses(@PathVariable long vehicleId, @Valid @RequestBody VehicleCareFinanceModels.Query request) { + return ApiResponse.success(service.statuses(vehicleId, request.recordNos())); + } + @PostMapping("/{recordNo}/finance") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-finance-submit')") + public ApiResponse submit(@PathVariable long vehicleId, @PathVariable String recordNo, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody VehicleCareFinanceModels.Submit request) { + return ApiResponse.success(service.submit(vehicleId, recordNo, user.userId().toString(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java new file mode 100644 index 000000000..a303b06ed --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@FeignClient(name="finance-service", contextId="vehicleCareFinance", url="${baiyue.clients.finance.url:}") +public interface VehicleCareFinanceClient { + @PostMapping("/internal/finance/v1/vehicle-costs/sources/query") + ApiResponse> query(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody VehicleCareFinanceModels.SourceQuery request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java new file mode 100644 index 000000000..b1684c94e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.event.EventEnvelope; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** The fee slice owns currency conversion and the shared reliable outbox. */ +public interface VehicleCareFinanceGateway { + record Conversion(String originalCurrency, BigDecimal originalAmount, BigDecimal exchangeRate, + String exchangeRateMonth, BigDecimal settlementAmount) { } + Conversion convert(String currency, BigDecimal amount, LocalDate businessDate); + /** Must join the caller's transaction; failure must roll back the fleet source mapping too. */ + void publish(EventEnvelope event); + Map deliveryStates(List eventKeys); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java new file mode 100644 index 000000000..d5930ee6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class VehicleCareFinanceModels { + private VehicleCareFinanceModels() { } + public record Submit(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long supplierId, + @NotNull LocalDate businessDate, @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Query(@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> recordNos) { } + public record Source(String sourceType, String sourceNo, long sourceVersion, String vehicleId, String vehiclePlate, + String tripNo, long supplierId, String feeName, LocalDate businessDate, String originalCurrency, BigDecimal originalAmount, + BigDecimal exchangeRate, String exchangeRateMonth, BigDecimal amount, String remark) { } + public record SourceKey(String sourceType, String sourceNo) { } + public record SourceQuery(List sources) { } + public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, String status, + BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { } + public record Status(String recordNo, String status, Long sourceVersion, Long submittedRecordVersion, Cost finance) { } + public record Submitted(String recordNo, long sourceVersion, long submittedRecordVersion, boolean replayed, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java new file mode 100644 index 000000000..76015dacb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java @@ -0,0 +1,149 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +@Service +public class VehicleCareFinanceService { + private static final String EVENT_TYPE = EventTypes.COMPANY_VEHICLE_COST_SUBMITTED; + private final VehicleCareMapper records; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final VehicleCareFinanceGateway accounting; + private final VehicleCareFinanceClient finance; + private final VehicleCareSupplierClient suppliers; + private final String token; + record Submission(long recordId, long sourceVersion, long recordVersion, String eventId, String requestHash, String payload) { } + public VehicleCareFinanceService(VehicleCareMapper records, JdbcTemplate jdbc, ObjectMapper json, VehicleCareFinanceGateway accounting, + VehicleCareFinanceClient finance, VehicleCareSupplierClient suppliers, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.records = records; this.jdbc = jdbc; this.json = json; this.accounting = accounting; + this.finance = finance; this.suppliers = suppliers; this.token = token; + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCareFinanceModels.Submitted submit(long vehicleId, String number, String operator, VehicleCareFinanceModels.Submit command) { + var row = records.lock(vehicleId, number); requireCostRecord(row); + String hash = hash(command); + var prior = jdbc.query("SELECT * FROM ll_fleet_finance_source WHERE record_id=? AND operator_id=? AND request_key=?", + (r, i) -> submission(r), row.getId(), operator, command.idempotencyKey()); + if (!prior.isEmpty()) { + if (!hash.equals(prior.get(0).requestHash())) throw conflict("财务提交标识已用于其他内容"); + return new VehicleCareFinanceModels.Submitted(number, prior.get(0).sourceVersion(), prior.get(0).recordVersion(), true, "PENDING_FINANCE"); + } + if (!row.getRecordVersion().equals(command.recordVersion())) throw conflict("运维记录已变化,请重新核对后提交"); + var latest = latest(row.getId()); + var current = queryFinance(List.of(row)).get(number); // Fail closed if Finance cannot establish current facts. + if (latest != null) { + if (current == null || current.source().sourceVersion() != latest.sourceVersion()) throw conflict("上次提交尚未完成财务接收,请稍后核对"); + if (!"VOIDED".equals(current.status())) throw conflict("费用已提交,重新登记须先在财务作废;已付款费用不可改写"); + } else if (current != null) throw conflict("财务来源已有记录但本地提交关系不一致,请核对"); + var details = draft(row); var incident = details.incident(); + if (incident == null || incident.reportedCost() == null || incident.reportedCost().signum() <= 0) throw bad("请先保存正数申报费用;未填写或明确零费用不生成财务应付"); + requireSupplier(command.supplierId()); + var converted = accounting.convert(incident.currency(), incident.reportedCost(), command.businessDate()); + if (converted.settlementAmount().signum() <= 0 || converted.settlementAmount().precision() - converted.settlementAmount().scale() > 15) + throw bad("折算金额必须为正数且在财务支持范围内"); + var source = new VehicleCareFinanceModels.Source(row.getRecordType(), row.getRecordNo(), latest == null ? 1 : latest.sourceVersion() + 1, + row.getVehicleId().toString(), row.getVehiclePlate(), null, command.supplierId(), incident.item(), command.businessDate(), + converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount(), details.remark()); + if (source.feeName() == null || source.feeName().length() > 200) throw bad("提交财务的维修或救援项目须在200字以内"); + var payload = json.valueToTree(source); String eventId = UUID.randomUUID().toString(); + String eventKey = EVENT_TYPE + ':' + row.getRecordType() + ':' + number + ':' + source.sourceVersion(); + var envelope = new EventEnvelope(eventId, EVENT_TYPE, 1, Instant.now(), "0", "COMPANY_VEHICLE_COST", + row.getRecordType() + ':' + number, source.sourceVersion(), null, UUID.randomUUID().toString(), eventKey, null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); + jdbc.update("INSERT INTO ll_fleet_finance_source(id,record_id,source_version,record_version,operator_id,request_key,request_hash,event_id,payload_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(), row.getId(), source.sourceVersion(), row.getRecordVersion(), operator, command.idempotencyKey(), hash, eventId, write(source), LocalDateTime.now(ZoneOffset.UTC)); + accounting.publish(envelope); + return new VehicleCareFinanceModels.Submitted(number, source.sourceVersion(), row.getRecordVersion(), false, "PENDING_FINANCE"); + } + public List statuses(long vehicleId, List numbers) { + if (numbers == null || numbers.isEmpty() || numbers.size() > 100 || numbers.stream().anyMatch(n -> n == null || n.isBlank())) throw bad("财务状态查询须提供1至100个有效记录号"); + var recordsByNo = records.findFinanceRecords(vehicleId, List.copyOf(new LinkedHashSet<>(numbers))); + if (recordsByNo.size() != new HashSet<>(numbers).size()) throw missing(); + var latestById = latest(recordsByNo.stream().map(VehicleCareEntity::getId).toList()); + var remote = latestById.isEmpty() ? Map.of() : queryFinance(recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())).toList()); + var deliveryKeys = recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())) + .map(r -> EVENT_TYPE + ':' + r.getRecordType() + ':' + r.getRecordNo() + ':' + latestById.get(r.getId()).sourceVersion()).toList(); + var deliveries = accounting.deliveryStates(deliveryKeys); + return recordsByNo.stream().map(row -> { + var local = latestById.get(row.getId()); if (local == null) return new VehicleCareFinanceModels.Status(row.getRecordNo(), "UNLINKED", null, null, null); + var cost = remote.get(row.getRecordNo()); + if (cost == null || cost.source().sourceVersion() < local.sourceVersion()) { + var delivery = deliveries.get(EVENT_TYPE + ':' + row.getRecordType() + ':' + row.getRecordNo() + ':' + local.sourceVersion()); + if (delivery == null) throw unavailable("财务投递关系缺失,状态尚未确认"); + return new VehicleCareFinanceModels.Status(row.getRecordNo(), "DEAD".equals(delivery) ? "FINANCE_ATTENTION" : "PENDING_FINANCE", local.sourceVersion(), local.recordVersion(), null); + } + if (cost.source().sourceVersion() != local.sourceVersion()) throw conflict("财务来源版本与本地提交不一致"); + if (!hash(cost.source()).equals(hash(read(local.payload(), VehicleCareFinanceModels.Source.class)))) throw conflict("财务来源内容与本地提交快照不一致"); + return new VehicleCareFinanceModels.Status(row.getRecordNo(), cost.status(), local.sourceVersion(), local.recordVersion(), cost); + }).toList(); + } + /** Called under the existing record lock; ordinary processing updates do not depend on Finance. */ + public void guardFinancialEdit(VehicleCareEntity row, VehicleCareModels.Draft next) { + if (!Set.of("REPAIR", "RESCUE").contains(row.getRecordType())) return; + var before = draft(row); + if (Objects.equals(before.incident().item(), next.incident().item()) + && Objects.equals(before.incident().currency(), next.incident().currency()) + && equalAmount(before.incident().reportedCost(), next.incident().reportedCost())) return; + var latest = latest(row.getId()); if (latest == null) return; + var remote = queryFinance(List.of(row)).get(row.getRecordNo()); + if (remote == null || remote.source().sourceVersion() != latest.sourceVersion()) throw conflict("财务接收状态未确认,暂不能更改费用字段"); + if (!"VOIDED".equals(remote.status())) throw conflict("已提交费用须先在财务作废,已付款费用禁止改写"); + } + private Map queryFinance(List rows) { + requireToken(); + try { + var keys = rows.stream().map(r -> new VehicleCareFinanceModels.SourceKey(r.getRecordType(), r.getRecordNo())).toList(); + var response = finance.query(token, new VehicleCareFinanceModels.SourceQuery(keys)); + if (response == null || !response.successful() || response.data() == null) throw unavailable("财务状态读取失败,未确认入账或付款"); + var result = new HashMap(); + for (var cost : response.data()) { + if (cost == null || cost.source() == null || !"PLATFORM_DEFAULT".equals(cost.subjectCode()) || !"CNY".equals(cost.currency()) + || !Set.of("PENDING","APPROVED","PARTIALLY_PAID","PAID","VOIDED").contains(Objects.toString(cost.status(), "")) + || !keys.contains(new VehicleCareFinanceModels.SourceKey(cost.source().sourceType(), cost.source().sourceNo())) + || rows.stream().noneMatch(r -> r.getRecordNo().equals(cost.source().sourceNo()) && r.getVehicleId().toString().equals(cost.source().vehicleId())) + || cost.paidAmount() == null || cost.paidAmount().signum() < 0 + || result.put(cost.source().sourceNo(), cost) != null) throw unavailable("财务返回的来源归属或状态无效"); + } + return result; + } catch (feign.FeignException error) { throw unavailable("Finance暂不可用,请稍后核对"); } + } + private void requireSupplier(long id) { + requireToken(); + try { var r = suppliers.get(token, id, true); + if (r == null || !r.successful() || r.data() == null || r.data().supplierId() != id || !"ACTIVE".equals(r.data().status())) throw bad("供应商不存在或未启用"); + } catch (feign.FeignException error) { throw unavailable("供应商主数据暂不可用"); } + } + private Submission latest(long recordId) { return latest(List.of(recordId)).get(recordId); } + private Map latest(List ids) { + if (ids.isEmpty()) return Map.of(); + var rows = jdbc.query("SELECT s.* FROM ll_fleet_finance_source s JOIN (SELECT record_id,MAX(source_version) source_version FROM ll_fleet_finance_source WHERE record_id IN (" + placeholders(ids.size()) + ") GROUP BY record_id) v ON v.record_id=s.record_id AND v.source_version=s.source_version", + (r, i) -> submission(r), ids.toArray()); + var map = new HashMap(); rows.forEach(r -> map.put(r.recordId(), r)); return map; + } + private static Submission submission(java.sql.ResultSet r) throws java.sql.SQLException { return new Submission(r.getLong("record_id"), r.getLong("source_version"), r.getLong("record_version"), r.getString("event_id"), r.getString("request_hash"), r.getString("payload_json")); } + private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); } + private VehicleCareModels.Draft draft(VehicleCareEntity row) { return read(row.getDetailsJson(), VehicleCareModels.Draft.class); } + private static boolean equalAmount(BigDecimal a, BigDecimal b) { return a == null ? b == null : b != null && a.compareTo(b) == 0; } + private String hash(Object value) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(value))); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法保存", e); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法读取", e); } } + private static void requireCostRecord(VehicleCareEntity row) { if (row == null || !Set.of("REPAIR","RESCUE").contains(row.getRecordType())) throw missing(); } + private void requireToken() { if (token == null || token.isBlank()) throw unavailable("内部服务身份未配置"); } + private static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, text); } + private static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT, text); } + private static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, text); } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前车辆维修救援记录不存在"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java index d7c9e1b6b..57e08c11a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java @@ -5,6 +5,10 @@ import org.apache.ibatis.annotations.*; @Mapper public interface VehicleCareMapper extends BaseMapper { + @Select({""}) + java.util.List findFinanceRecords(@Param("vehicleId") long vehicleId, @Param("numbers") java.util.List numbers); @Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}") VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo); @Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java index 20968c2ab..93ec5c040 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java @@ -27,9 +27,10 @@ public class VehicleCareService { private final VehicleRepository vehicles; private final VehicleCareFileGateway files; private final ObjectMapper json; + private final VehicleCareFinanceService finance; public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories, - VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json) { - this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json; + VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json, VehicleCareFinanceService finance) { + this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json; this.finance = finance; } public PageResult page(long vehicleId, VehicleCareModels.Query query) { vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing); @@ -65,6 +66,7 @@ public class VehicleCareService { public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) { var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details()); if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型"); + finance.guardFinancialEdit(row, request.details()); if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因"); row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); persist(row, request.recordVersion(), "UPDATE", request.reason(), operator); @@ -121,7 +123,7 @@ public class VehicleCareService { return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(), read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(), row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC), - Set.of("REPAIR", "RESCUE").contains(row.getRecordType()) ? "UNLINKED" : null); + null); // Current financial facts are queried in a separate bounded batch, never frozen into operational history. } private VehicleCareEntity required(long vehicleId, String number, boolean lock) { var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java new file mode 100644 index 000000000..c6c7f7734 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name="warehouse-service", contextId="vehicleCareSupplier", url="${baiyue.clients.warehouse.url:}") +public interface VehicleCareSupplierClient { + @GetMapping("/internal/warehouse/v1/suppliers/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long supplierId, + @RequestParam boolean requireActive); + record Supplier(long supplierId, String name, String category, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java new file mode 100644 index 000000000..174978e3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class FleetQuoteDirectory { + private final FleetCustomerDirectory customers; + private final FleetQuoteSupplierClient suppliers; + private final String token; + public FleetQuoteDirectory(FleetCustomerDirectory customers,FleetQuoteSupplierClient suppliers,@Value("${baiyue.security.internal-service-token}") String token) { + this.customers=customers;this.suppliers=suppliers;this.token=token; + } + public String name(String kind,long id) { + if("SALE".equals(kind)) return customers.required(id).name(); + try { + var result=suppliers.get(token,id,true,"TRUCKING"); + if(result==null || !result.successful() || result.data()==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用"); + var row=result.data(); + if(row.supplierId()!=id || !"ACTIVE".equals(row.status()) || !"TRUCKING".equals(row.category()) || !"OVERSEAS".equals(row.truckingScope())) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效的海外车队供应商"); + return row.name(); + } catch(ResponseStatusException e) { throw e; } + catch(RuntimeException e) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用,请重试"); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java new file mode 100644 index 000000000..222ae6e60 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.quote; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; + +public final class FleetQuoteModels { + private FleetQuoteModels() { } + public record Draft(@NotBlank @Pattern(regexp="PURCHASE|SALE") String kind, @NotNull @Positive Long partyId, + @NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode, + @NotNull @DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal distanceMiles, + @NotBlank @Pattern(regexp="USD|CNY") String currency, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal selfPickupPrice, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal fullDeliveryPrice, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal looseDeliveryPrice, + @NotNull LocalDate validFrom, @NotNull LocalDate validTo, + @Size(max=1000) String remark) { } + public record Create(@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotNull @Valid Draft details) { } + public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details) { } + public record Status(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp="ACTIVE|INACTIVE") String status) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @Pattern(regexp="PURCHASE|SALE") String kind, @Size(max=128) String keyword, + @Pattern(regexp="ACTIVE|INACTIVE") String status, LocalDate effectiveOn) { + public Query { page=page==null?1:page; size=size==null?20:size; } + } + public record Quote(String quoteNo, Draft details, String partyName, String status, long recordVersion, Instant updatedAt) { } + public record History(long recordVersion, String action, Instant occurredAt, Quote snapshot) { } + public record Import(@NotBlank @Size(min=8,max=100) String idempotencyKey, @NotEmpty @Size(max=200) List<@Valid @NotNull Draft> rows) { } + public record ImportResult(int importedRows, List records) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java new file mode 100644 index 000000000..181ada62e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.quote; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.*; + +@Repository +public class FleetQuoteRepository { + record Row(long id,String quoteNo,String details,String partyName,String status,long version,String createHash,LocalDateTime updatedAt) { } + record Audit(long version,String action,LocalDateTime occurredAt,String snapshot) { } + private final JdbcTemplate jdbc; + public FleetQuoteRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; } + private static final RowMapper MAPPER=(r,i)->new Row(r.getLong("id"),r.getString("quote_no"),r.getString("details_json"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("create_hash"),r.getTimestamp("updated_at").toLocalDateTime()); + Row find(String no,boolean lock) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE quote_no=?"+(lock?" FOR UPDATE":""),MAPPER,no).stream().findFirst().orElse(null); } + Row replay(String operator,String key) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE created_by=? AND create_key=?",MAPPER,operator,key).stream().findFirst().orElse(null); } + String creationSnapshot(long id) { return jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_quote_history WHERE quote_id=? AND record_version=0",String.class,id); } + List list(FleetQuoteModels.Query q) { + StringBuilder sql=new StringBuilder("SELECT * FROM ll_fleet_quote WHERE status<>'DELETED'"); var args=new ArrayList(); + if(q.kind()!=null&&!q.kind().isBlank()) {sql.append(" AND kind=?");args.add(q.kind());} + if(q.status()!=null&&!q.status().isBlank()) {sql.append(" AND status=?");args.add(q.status());} + if(q.effectiveOn()!=null) {sql.append(" AND valid_from<=? AND valid_to>=? AND status='ACTIVE'");args.add(q.effectiveOn());args.add(q.effectiveOn());} + if(q.keyword()!=null&&!q.keyword().isBlank()) {sql.append(" AND (origin_city LIKE ? OR destination_code LIKE ? OR party_name LIKE ? OR quote_no LIKE ?)");for(int i=0;i<4;i++) args.add("%"+q.keyword().trim()+"%");} + sql.append(" ORDER BY updated_at DESC,id DESC LIMIT 5001"); return jdbc.query(sql.toString(),MAPPER,args.toArray()); + } + void insert(Row r,FleetQuoteModels.Draft d,String natural,String operator,String key) { + jdbc.update("INSERT INTO ll_fleet_quote(id,quote_no,kind,party_id,party_name,origin_city,destination_code,valid_from,valid_to,status,natural_key,details_json,record_version,create_key,create_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + r.id(),r.quoteNo(),d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),key,r.createHash(),operator,r.updatedAt(),r.updatedAt()); + } + void update(Row r,FleetQuoteModels.Draft d,String natural) { + jdbc.update("UPDATE ll_fleet_quote SET kind=?,party_id=?,party_name=?,origin_city=?,destination_code=?,valid_from=?,valid_to=?,status=?,natural_key=?,details_json=?,record_version=?,updated_at=? WHERE id=?", + d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),r.updatedAt(),r.id()); + } + void audit(long id,Row r,String action,String operator,String snapshot) { + jdbc.update("INSERT INTO ll_fleet_quote_history(id,quote_id,record_version,action,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?)",id,r.id(),r.version(),action,operator,r.updatedAt(),snapshot); + } + List history(long id) {return jdbc.query("SELECT * FROM ll_fleet_quote_history WHERE quote_id=? ORDER BY record_version DESC LIMIT 200",(r,i)->new Audit(r.getLong("record_version"),r.getString("action"),r.getTimestamp("occurred_at").toLocalDateTime(),r.getString("snapshot_json")),id);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java new file mode 100644 index 000000000..86f480fe3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.*; + +@Service +public class FleetQuoteService { + private final FleetQuoteRepository repository; private final FleetQuoteDirectory directory; private final ObjectMapper json; + public FleetQuoteService(FleetQuoteRepository repository,FleetQuoteDirectory directory,ObjectMapper json) {this.repository=repository;this.directory=directory;this.json=json;} + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult page(FleetQuoteModels.Query q) { + var rows=list(q);long total=rows.size();int start=(int)Math.min(total,((long)q.page()-1)*q.size()); + return new PageResult<>(rows.subList(start,Math.min(rows.size(),start+q.size())),q.page(),q.size(),total,(total+q.size()-1)/q.size()); + } + private List list(FleetQuoteModels.Query q) { + var rows=repository.list(q);if(rows.size()>5000)throw bad("超过5000条报价,请缩小筛选范围");return rows.stream().map(this::response).toList(); + } + @Transactional(readOnly=true) public FleetQuoteModels.Quote detail(String no) {return response(required(no,false));} + @Transactional(readOnly=true) public List history(String no) { + return repository.history(required(no,false).id()).stream().map(r->new FleetQuoteModels.History(r.version(),r.action(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.snapshot(),FleetQuoteModels.Quote.class))).toList(); + } + @Transactional public FleetQuoteModels.Quote create(String operator,FleetQuoteModels.Create request) {return createOne(operator,request,"");} + private FleetQuoteModels.Quote createOne(String operator,FleetQuoteModels.Create request,String batchHash) { + var draft=normalize(request.details());String hash=hash(batchHash+write(draft));var existing=repository.replay(operator,request.idempotencyKey()); + if(existing!=null) {if(!existing.createHash().equals(hash))throw conflict("该请求键已用于不同报价");return read(repository.creationSnapshot(existing.id()),FleetQuoteModels.Quote.class);} + String name=directory.name(draft.kind(),draft.partyId());long id=IdWorker.getId(); + var row=new FleetQuoteRepository.Row(id,"FQ"+id,write(draft),name,"ACTIVE",0,hash,LocalDateTime.now(ZoneOffset.UTC)); + try {repository.insert(row,draft,natural(draft),operator,request.idempotencyKey());} + catch(DuplicateKeyException e) {throw conflict("报价重复或请求正在处理,请核对报价库后使用原请求重试");} + audit(row,"CREATE",operator);return response(row); + } + @Transactional public FleetQuoteModels.Quote update(String no,String operator,FleetQuoteModels.Update request) { + var old=required(no,true);version(old,request.recordVersion());var draft=normalize(request.details()); + if(!draft.kind().equals(read(old.details(),FleetQuoteModels.Draft.class).kind())) throw bad("报价用途不能变更,请另建采购或销售报价"); + var row=new FleetQuoteRepository.Row(old.id(),no,write(draft),directory.name(draft.kind(),draft.partyId()),old.status(),old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC)); + try {repository.update(row,draft,natural(draft));}catch(DuplicateKeyException e){throw conflict("同一报价组合已存在");} + audit(row,"UPDATE",operator);return response(row); + } + @Transactional public FleetQuoteModels.Quote status(String no,String operator,FleetQuoteModels.Status request) {return changeStatus(no,operator,request.recordVersion(),request.status());} + @Transactional public FleetQuoteModels.Quote delete(String no,String operator,long version) {return changeStatus(no,operator,version,"DELETED");} + private FleetQuoteModels.Quote changeStatus(String no,String operator,long expected,String status) { + var old=required(no,true);version(old,expected);var draft=read(old.details(),FleetQuoteModels.Draft.class); + if("ACTIVE".equals(status))directory.name(draft.kind(),draft.partyId()); + var row=new FleetQuoteRepository.Row(old.id(),no,old.details(),old.partyName(),status,old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC)); + repository.update(row,draft,"DELETED".equals(status)?hash("DELETED:"+no):natural(draft));audit(row,status,operator);return response(row); + } + @Transactional public FleetQuoteModels.ImportResult importRows(String operator,FleetQuoteModels.Import request) { + var rows=new ArrayList(); + String batchHash=hash(write(request.rows().stream().map(FleetQuoteService::normalize).toList())); + for(int i=0;iT read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("报价快照无法读取",e);}} + private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + private static String cell(Object o){String s=o==null?"":o.toString();if(s.matches("(?s)^[\\s]*[=+@-].*"))s="'"+s;return "\""+s.replace("\"","\"\"")+"\"";} + private static ResponseStatusException bad(String s){return new ResponseStatusException(HttpStatus.BAD_REQUEST,s);} + private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java new file mode 100644 index 000000000..a7a469fa3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@FeignClient(name="warehouse-service",contextId="fleetQuoteSupplierClient",url="${baiyue.clients.warehouse.url:}") +public interface FleetQuoteSupplierClient { + @GetMapping("/internal/warehouse/v1/suppliers/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("supplierId") long supplierId, + @RequestParam("requireActive") boolean requireActive,@RequestParam("category") String category); + record Supplier(long supplierId,String name,String category,String status,List carrierCodes,String truckingScope) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java new file mode 100644 index 000000000..fcb1bac40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/quotes") +public class PlatformFleetQuoteController { + private final FleetQuoteService service; private final FleetCustomerDirectory customers; + public PlatformFleetQuoteController(FleetQuoteService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;} + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> page(@Valid FleetQuoteModels.Query query){return ApiResponse.success(service.page(query));} + @GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> customers(@RequestParam(required=false) @Size(max=128) String keyword){return ApiResponse.success(customers.options(keyword));} + @GetMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse detail(@PathVariable String quoteNo){return ApiResponse.success(service.detail(quoteNo));} + @GetMapping("/{quoteNo}/history") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> history(@PathVariable String quoteNo){return ApiResponse.success(service.history(quoteNo));} + @PostMapping @PreAuthorize("hasAuthority('platform:fleet:quote:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Create request){return ApiResponse.success(service.create(operator(user),request));} + @PutMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:edit')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Update request){return ApiResponse.success(service.update(quoteNo,operator(user),request));} + @PatchMapping("/{quoteNo}/status") @PreAuthorize("hasAuthority('platform:fleet:quote:status')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Status request){return ApiResponse.success(service.status(quoteNo,operator(user),request));} + @DeleteMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:delete')") + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@RequestParam @Min(0) long recordVersion){return ApiResponse.success(service.delete(quoteNo,operator(user),recordVersion));} + @PostMapping("/import") @PreAuthorize("hasAuthority('platform:fleet:quote:import')") + public ApiResponse importRows(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Import request){return ApiResponse.success(service.importRows(operator(user),request));} + @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:quote:export')") + public ResponseEntity export(@Valid FleetQuoteModels.Query query){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=fleet-quotes.csv").body(service.exportCsv(query));} + private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护报价");return user.userId().toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java new file mode 100644 index 000000000..db01842bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/operations") +public class PlatformTripOperationsController { + private final TripOperationsService service; + public PlatformTripOperationsController(TripOperationsService service) { this.service=service; } + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); } + @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')") + public ResponseEntity export(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { + return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")) + .header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user))); + } + private static boolean canReadCosts(AuthenticatedUser user){return user!=null&&user.permissions().contains("platform:fleet:trip:list");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java new file mode 100644 index 000000000..9d764b8c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/operations/{tripNo}/record") +public class PlatformTripOperationsRecordController { + private final TripOperationsRecordService service; + public PlatformTripOperationsRecordController(TripOperationsRecordService service){this.service=service;} + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));} + @GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse> history(@PathVariable String tripNo,@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(100) int size){return ApiResponse.success(service.history(tripNo,page,size));} + @PutMapping @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody TripOperationsRecordModels.Save request){ + if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护实测记录"); + return ApiResponse.success(service.save(tripNo,user.userId().toString(),request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java new file mode 100644 index 000000000..01b0d6991 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.reporting; + +import java.math.*; +import java.time.ZoneOffset; +import java.util.*; + +/** Every chart uses the exact same filtered departures as the table and summary. */ +final class TripOperationsCharts { + private TripOperationsCharts() { } + static TripOperationsModels.Charts from(List rows) { + var pallets=new TreeMap();var destinations=new TreeMap();var labels=new HashMap(); + var amounts=new TreeMap>();var rateAmounts=new TreeMap>();var quantities=new TreeMap>(); + for(var row:rows) { + String day=row.departedAt().atOffset(ZoneOffset.UTC).toLocalDate().toString(); + pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add); + for(var destination:row.destinations()) {destinations.merge(destination.key(),BigDecimal.ONE,BigDecimal::add);labels.put(destination.key(),destination.label());} + for(var money:row.externalAgreedAmounts())amounts.computeIfAbsent(money.currency(),ignored->new TreeMap<>()).merge(day,money.amount(),BigDecimal::add); + for(var rate:row.externalPalletRates()) { + rateAmounts.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,rate.agreedAmount(),BigDecimal::add); + quantities.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,BigDecimal.valueOf(rate.pricedPalletSpaces()),BigDecimal::add); + } + } + var average=new TreeMap>(); + rateAmounts.forEach((currency,days)->days.forEach((day,amount)->average.computeIfAbsent(currency,ignored->new TreeMap<>()).put(day, + amount.divide(quantities.get(currency).get(day),2,RoundingMode.HALF_UP)))); + var destinationPoints=destinations.entrySet().stream().map(e->new TripOperationsModels.Point(labels.get(e.getKey()),e.getValue())).toList(); + return new TripOperationsModels.Charts(List.of(new TripOperationsModels.Series("每日实运托盘","托",points(pallets))), + List.of(new TripOperationsModels.Series("目的地涉及车次","次",destinationPoints)),series(amounts,false),series(average,true)); + } + private static List points(Map values){return values.entrySet().stream().map(e->new TripOperationsModels.Point(e.getKey(),e.getValue())).toList();} + private static List series(Map> values,boolean average){return values.entrySet().stream().map(e->new TripOperationsModels.Series(e.getKey(),e.getKey()+(average?"/标准托位":""),points(e.getValue()))).toList();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java new file mode 100644 index 000000000..7111a9304 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.trip.FleetTripCostModels; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class TripOperationsModels { + private TripOperationsModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @NotNull Instant from, @NotNull Instant to, @Positive Long warehouseId, + @Size(max=128) String keyword, @Pattern(regexp="OWN|EXTERNAL") String sourceType, + @Pattern(regexp="IN_TRANSIT|COMPLETED") String status) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Money(String currency, BigDecimal amount) { } + public record Destination(String key,String label) { } + public record PalletRate(String currency,long pricedPalletSpaces,BigDecimal agreedAmount) { } + public record Point(String label,BigDecimal value) { } + public record Series(String label,String unit,List points) { } + public record Charts(List physicalPallets,List destinationTrips,List externalAgreedAmounts,List externalPalletRates) { } + public record Row(String tripNo, String status, String vehiclePlate, String driverName, + String warehouseName, Instant departedAt, Instant completedAt, List destinations, + long physicalPallets, long palletSpaces, long ownPackages, long externalPackages, + List externalAgreedAmounts, List externalPalletRates, TripOperationsRecordModels.Details measured, + String financeStatus, String costStatus, FleetTripCostModels.Summary costs) { + Row withCosts(FleetTripCostModels.Summary value, String state) { + return new Row(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts,externalPalletRates,measured,financeStatus,state,value); + } + } + public record CostTotal(String currency,BigDecimal directCost,BigDecimal ownCost,BigDecimal externalCost) { } + public record CostSummary(boolean visible,Long unrecordedTrips,Long unallocatedTrips,List totals) { } + public record Summary(long trips, long inTransit, long completed, long physicalPallets, long palletSpaces, + long ownPackages, long externalPackages, List externalAgreedAmounts,CostSummary costs) { } + public record Result(PageResult page, Summary summary, Charts charts) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java new file mode 100644 index 000000000..f82624ace --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.fleet.reporting; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; + +public final class TripOperationsRecordModels { + private TripOperationsRecordModels() { } + public record Details( + @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualDistanceMiles, + @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualFuelLitres, + @Size(max=1000) String remark) { } + public record Save(@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotNull @Valid Details details, @Size(max=1000) String reason) { } + public record Record(String tripNo, long recordVersion, Details details, Instant updatedAt) { } + public record History(long recordVersion, String reason, Instant occurredAt, Details details) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java new file mode 100644 index 000000000..d2f4b46ad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class TripOperationsRecordRepository { + record Snapshot(long version,String details,String reason,String requestHash,LocalDateTime occurredAt) { } + private final JdbcTemplate jdbc; + private static final RowMapper MAPPER=(r,i)->new Snapshot(r.getLong("record_version"),r.getString("details_json"),r.getString("reason"),r.getString("request_hash"),r.getTimestamp("occurred_at").toLocalDateTime()); + public TripOperationsRecordRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; } + boolean hasDeparted(String tripNo,boolean lock) { + return jdbc.query("SELECT departed_at FROM ll_fleet_trip WHERE trip_no=?"+(lock?" FOR UPDATE":""), + (r,i)->r.getTimestamp("departed_at")!=null,tripNo).stream().findFirst().orElse(false); + } + Snapshot latest(String tripNo) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT 1",MAPPER,tripNo).stream().findFirst().orElse(null); + } + Snapshot replay(String tripNo,String operator,String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? AND operator_id=? AND request_key=?",MAPPER,tripNo,operator,key).stream().findFirst().orElse(null); + } + void append(long id,String tripNo,String operator,String key,Snapshot row) { + jdbc.update("INSERT INTO ll_fleet_trip_operations_record(id,trip_no,record_version,details_json,reason,operator_id,request_key,request_hash,occurred_at) VALUES(?,?,?,?,?,?,?,?,?)", + id,tripNo,row.version(),row.details(),row.reason(),operator,key,row.requestHash(),row.occurredAt()); + } + long count(String tripNo) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record WHERE trip_no=?",Long.class,tripNo); } + List history(String tripNo,int page,int size) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT ? OFFSET ?",MAPPER,tripNo,size,((long)page-1)*size); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java new file mode 100644 index 000000000..7b6d9ad21 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.HexFormat; + +/** Append-only measured operating facts; never updates execution, vehicle odometers or costs. */ +@Service +public class TripOperationsRecordService { + private final TripOperationsRecordRepository repository; + private final ObjectMapper json; + public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json) { this.repository=repository;this.json=json; } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public TripOperationsRecordModels.Record get(String tripNo) { + requireDeparture(tripNo,false);return response(tripNo,repository.latest(tripNo)); + } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult history(String tripNo,int page,int size) { + requireDeparture(tripNo,false);long total=repository.count(tripNo); + var rows=repository.history(tripNo,page,size).stream().map(r->new TripOperationsRecordModels.History(r.version(),r.reason(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.details()))).toList(); + return new PageResult<>(rows,page,size,total,(total+size-1)/size); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public TripOperationsRecordModels.Record save(String tripNo,String operator,TripOperationsRecordModels.Save request) { + requireDeparture(tripNo,true); + var d=request.details();var details=new TripOperationsRecordModels.Details(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark())); + String reason=trim(request.reason());if(reason==null)reason="补充或更正出车实测记录"; + String contents=write(details);String hash=hash(request.recordVersion()+":"+contents+":"+reason); + var replay=repository.replay(tripNo,operator,request.idempotencyKey()); + if(replay!=null) {if(!replay.requestHash().equals(hash))throw conflict("原请求键已用于其他实测内容");return response(tripNo,replay);} + var previous=repository.latest(tripNo);long version=previous==null?0:previous.version(); + if(version!=request.recordVersion())throw conflict("实测记录已变化,请重新加载后更正"); + if(previous!=null && previous.details().equals(contents))return response(tripNo,previous); + if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null)return response(tripNo,null); + var row=new TripOperationsRecordRepository.Snapshot(version+1,contents,reason,hash,LocalDateTime.now(ZoneOffset.UTC)); + repository.append(IdWorker.getId(),tripNo,operator,request.idempotencyKey(),row);return response(tripNo,row); + } + private void requireDeparture(String tripNo,boolean lock) {if(!repository.hasDeparted(tripNo,lock))throw new ResponseStatusException(HttpStatus.CONFLICT,"仅实际已发车的车次可维护实测记录");} + private TripOperationsRecordModels.Record response(String tripNo,TripOperationsRecordRepository.Snapshot row) { + return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null),null) + :new TripOperationsRecordModels.Record(tripNo,row.version(),read(row.details()),row.occurredAt().toInstant(ZoneOffset.UTC)); + } + private TripOperationsRecordModels.Details read(String value) {try{return json.readValue(value,TripOperationsRecordModels.Details.class);}catch(JsonProcessingException e){throw new IllegalStateException("实测记录无法读取",e);}} + private String write(Object value) {try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}} + private static String trim(String s){return s==null||s.isBlank()?null:s.trim();} + private static BigDecimal decimal(BigDecimal value){return value==null?null:value.stripTrailingZeros();} + private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java new file mode 100644 index 000000000..762fc6041 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import java.time.*; +import java.util.*; + +@Repository +public class TripOperationsRepository { + record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured) { } + private final JdbcTemplate jdbc; + public TripOperationsRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + List departures(TripOperationsModels.Query query) { + StringBuilder sql = new StringBuilder(""" + SELECT t.trip_no,t.status,t.completed_at, + (SELECT h.snapshot_json FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' + ORDER BY h.record_version LIMIT 1) AS snapshot_json, + (SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no + ORDER BY r.record_version DESC LIMIT 1) AS measured_json + FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ? + """); + var args = new ArrayList(); + args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC)); + if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); } + if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); } + // Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot. + sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001"); + return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"), + r.getTimestamp("completed_at") == null ? null : r.getTimestamp("completed_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + r.getString("snapshot_json"),r.getString("measured_json")), args.toArray()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java new file mode 100644 index 000000000..13deda172 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +@Service +@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) +public class TripOperationsService { + private final TripOperationsRepository repository; + private final ObjectMapper json; + private final FleetTripCostService costs; + public TripOperationsService(TripOperationsRepository repository, ObjectMapper json,FleetTripCostService costs) { this.repository=repository; this.json=json;this.costs=costs; } + public TripOperationsModels.Result report(TripOperationsModels.Query query,boolean costVisible) { + var rows = rows(query,costVisible); long total = rows.size(); + int start = (int) Math.min(total, ((long) query.page()-1)*query.size()); + return new TripOperationsModels.Result(new PageResult<>(rows.subList(start, Math.min(rows.size(),start+query.size())), + query.page(),query.size(),total,(total+query.size()-1)/query.size()), summary(rows,costVisible),TripOperationsCharts.from(rows)); + } + List rows(TripOperationsModels.Query query,boolean costVisible) { + if (query.from() == null || query.to() == null || !query.to().isAfter(query.from()) + || Duration.between(query.from(),query.to()).compareTo(Duration.ofDays(366))>0) throw bad("请选择不超过366天的实际发车时间范围"); + var facts=repository.departures(query); + if (facts.size()>5000) throw bad("范围内超过5000车次,请缩小时间或仓库范围后查询或导出"); + String keyword=query.keyword()==null?"":query.keyword().trim().toLowerCase(Locale.ROOT); + var rows=facts.stream().map(this::project) + .filter(r -> keyword.isEmpty() || contains(r.tripNo(),keyword) || contains(r.vehiclePlate(),keyword) || contains(r.driverName(),keyword) || contains(r.warehouseName(),keyword) || r.destinations().stream().anyMatch(s -> contains(s.label(),keyword))) + .filter(r -> query.sourceType()==null || query.sourceType().isBlank() || ("OWN".equals(query.sourceType())?r.ownPackages()>0:r.externalPackages()>0)).toList(); + if(!costVisible)return rows.stream().map(r->r.withCosts(null,"FORBIDDEN")).toList(); + var result=new ArrayList(); + for(int start=0;start(); var money=new TreeMap(); var bookingNumbers=new HashSet(); + var rates=new ArrayList(); + if (snapshot.externalBookings()!=null) for (var booking:snapshot.externalBookings()) { + if (Set.of("DRAFT","CANCELLED").contains(booking.status())) continue; + if (!"BOUND".equals(booking.status()) || !bookingNumbers.add(booking.bookingNo())) throw new IllegalStateException("发车外接记录未完成关联或重复"); + if (booking.dispatchNo()==null || booking.agreedAmount()==null || booking.cargo()==null || booking.cargo().currency()==null) + throw new IllegalStateException("外接发车历史缺少约定报价或排车归属"); + if(booking.receipt()==null || booking.receipt().source()==null || booking.receipt().source().pallets()==null) + throw new IllegalStateException("外接发车历史缺少实收来源"); + long received=0; + for(var p:booking.receipt().source().pallets()) { + externalByPallet.merge(booking.dispatchNo()+":"+booking.customerId()+":"+p.warehousePalletId(),(long)p.packages(),Long::sum); + received+=p.packages(); + } + if(received!=booking.receipt().source().receivedPackages())throw new IllegalStateException("外接实收来源数量未核平"); + money.merge(booking.cargo().currency(),booking.agreedAmount(),BigDecimal::add); + if("PALLET".equals(booking.cargo().priceBasis())) { + if(booking.cargo().palletSpaces()==null || booking.cargo().palletSpaces()<1)throw new IllegalStateException("按托报价缺少确认计价托位"); + rates.add(new TripOperationsModels.PalletRate(booking.cargo().currency(),booking.cargo().palletSpaces(),booking.agreedAmount())); + } + } + var pallets=new HashSet(); long spaces=0; + for(var pallet:load.pallets()) { + if(!pallets.add(pallet.warehousePalletId())) throw new IllegalStateException("发车实物托盘重复"); + spaces+=pallet.palletSpaces(); + } + long own=0,external=0; var destinations=new TreeMap(); var seen=new HashSet(); + var shippedByPallet=new HashMap(); + for(var cargo:load.cargo()) { + if (!pallets.contains(cargo.warehousePalletId()) || !seen.add(cargo.dispatchNo()+":"+cargo.dispatchItemId())) + throw new IllegalStateException("发车装载货物未核平或重复"); + shippedByPallet.merge(cargo.dispatchNo()+":"+cargo.customerId()+":"+cargo.warehousePalletId(),(long)cargo.packages(),Long::sum); + if(cargo.destinationKey()==null || cargo.destinationLabel()==null)throw new IllegalStateException("发车货物缺少目的地依据"); + destinations.put(cargo.destinationKey(),cargo.destinationLabel()); + } + for(var entry:externalByPallet.entrySet()) if(entry.getValue()>shippedByPallet.getOrDefault(entry.getKey(),0L))throw new IllegalStateException("外接实收超过实际发车货量"); + external=externalByPallet.values().stream().mapToLong(Long::longValue).sum(); + own=shippedByPallet.values().stream().mapToLong(Long::longValue).sum()-external; + return new TripOperationsModels.Row(fact.tripNo(),fact.status(),trip.vehiclePlate(),trip.driverName(),trip.warehouseName(),trip.departedAt(),fact.completedAt(), + destinations.entrySet().stream().map(e->new TripOperationsModels.Destination(e.getKey(),e.getValue())).toList(),pallets.size(),spaces,own,external,amounts(money),List.copyOf(rates), + fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null); + } catch(JsonProcessingException e) { throw new IllegalStateException("发车历史无法读取,请核对车次"+fact.tripNo(),e); } + } + static TripOperationsModels.Summary summary(List rows,boolean costVisible) { + var money=new TreeMap(); + rows.forEach(row -> row.externalAgreedAmounts().forEach(m -> money.merge(m.currency(),m.amount(),BigDecimal::add))); + return new TripOperationsModels.Summary(rows.size(),rows.stream().filter(r -> "IN_TRANSIT".equals(r.status())).count(),rows.stream().filter(r -> "COMPLETED".equals(r.status())).count(), + rows.stream().mapToLong(TripOperationsModels.Row::physicalPallets).sum(),rows.stream().mapToLong(TripOperationsModels.Row::palletSpaces).sum(), + rows.stream().mapToLong(TripOperationsModels.Row::ownPackages).sum(),rows.stream().mapToLong(TripOperationsModels.Row::externalPackages).sum(),amounts(money),costSummary(rows,costVisible)); + } + private static TripOperationsModels.CostSummary costSummary(List rows,boolean visible) { + if(!visible)return new TripOperationsModels.CostSummary(false,null,null,List.of()); + var totals=new TreeMap();long unrecorded=0,unallocated=0; + for(var row:rows) { + if(row.costs().totals().isEmpty())unrecorded++; + else if(row.costs().allocationBlockReason()!=null)unallocated++; + for(var total:row.costs().totals()) { + var old=totals.get(total.currency()); + totals.put(total.currency(),old==null?new TripOperationsModels.CostTotal(total.currency(),total.directCost(),total.ownCost(),total.externalCost()) + :new TripOperationsModels.CostTotal(total.currency(),old.directCost().add(total.directCost()),addKnown(old.ownCost(),total.ownCost()),addKnown(old.externalCost(),total.externalCost()))); + } + } + return new TripOperationsModels.CostSummary(true,unrecorded,unallocated,List.copyOf(totals.values())); + } + private static BigDecimal addKnown(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);} + public byte[] exportCsv(TripOperationsModels.Query query,boolean costVisible) { + var csv=new StringBuilder("\uFEFF车次号,状态,车牌,司机,起运仓,实际发车UTC,货物结案UTC,目的地,实物托盘数,标准托位,自有箱数,外接箱数,外接约定金额(按币种),实际里程(mile),实际油耗(L),实测备注,应收与实收,直接成本\r\n"); + for(var r:rows(query,costVisible)) csv.append(String.join(",",cell(r.tripNo()),cell(r.status()),cell(r.vehiclePlate()),cell(r.driverName()),cell(r.warehouseName()),cell(r.departedAt()),cell(r.completedAt()), + cell(r.destinations().stream().map(TripOperationsModels.Destination::label).collect(java.util.stream.Collectors.joining(" / "))),cell(r.physicalPallets()),cell(r.palletSpaces()),cell(r.ownPackages()),cell(r.externalPackages()), + cell(r.externalAgreedAmounts().stream().map(m -> m.currency()+" "+m.amount().toPlainString()).collect(java.util.stream.Collectors.joining(" / "))), + cell(r.measured()==null?null:r.measured().actualDistanceMiles()),cell(r.measured()==null?null:r.measured().actualFuelLitres()),cell(r.measured()==null?null:r.measured().remark()),cell("未查询财务"),cell(costText(r)))).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + private static String costText(TripOperationsModels.Row row){return row.costs()==null?"无成本读取权限":row.costs().totals().isEmpty()?"未登记成本":row.costs().totals().stream().map(t->t.currency()+" "+t.directCost().toPlainString()).collect(java.util.stream.Collectors.joining(" / "));} + private static List amounts(Map money) { return money.entrySet().stream().map(e -> new TripOperationsModels.Money(e.getKey(),e.getValue())).toList(); } + private static boolean contains(String value,String key) { return value!=null && value.toLowerCase(Locale.ROOT).contains(key); } + static String cell(Object value) { String s=value==null?"":value.toString(); if(s.matches("(?s)^[\\s]*[=+@-].*")) s="'"+s; return "\""+s.replace("\"","\"\"")+"\""; } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java index bbae3c7f2..efcacaa39 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java @@ -25,6 +25,10 @@ public interface FleetDispatchGateway { void assign(Snapshot dispatch, Assignment assignment); FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock); FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock); + /** Actual departure/terminal evidence for an in-transit handover; does not replay departure. */ + com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock); + /** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */ + void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment); FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, String idempotencyKey, String remark, Instant departedAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java new file mode 100644 index 000000000..6570e542d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.trip; + +import java.util.*; + +/** Cost weights come only from the immutable physical departure, never a reservation or current inventory. */ +final class FleetTripCostBasis { + private FleetTripCostBasis() { } + record Share(String businessNo, String sourceType, String dispatchNo, long palletSpaces) { } + record Basis(String manifestHash, List shares, String blockReason) { } + + static Basis from(FleetTripModels.AuditSnapshot snapshot) { + if (snapshot == null || snapshot.confirmedLoad() == null) return blocked("尚无实际发车装载记录"); + var load = snapshot.confirmedLoad(); + if (load.pallets() == null || load.pallets().isEmpty() || load.cargo() == null || load.cargo().isEmpty()) + return blocked("发车装载记录缺少实运托盘或货物"); + var measured = new HashMap(); + for (var pallet : load.pallets()) { + if (pallet.palletSpaces() < 1 || measured.put(pallet.warehousePalletId(), pallet) != null) + return blocked("发车实测托位缺失或托盘重复"); + } + var cargoByPallet = new HashMap>(); + for (var cargo : load.cargo()) cargoByPallet.computeIfAbsent(cargo.warehousePalletId(), ignored -> new ArrayList<>()).add(cargo); + if (!measured.keySet().equals(cargoByPallet.keySet())) return blocked("发车货物与实测托盘未核平"); + var external = new HashMap(); + if (snapshot.externalBookings() != null) for (var booking : snapshot.externalBookings()) { + if ("DRAFT".equals(booking.status()) || "CANCELLED".equals(booking.status())) continue; + if (!"BOUND".equals(booking.status()) || booking.bookingNo() == null || booking.bookingNo().isBlank() + || booking.customerId() == null || booking.dispatchNo() == null || booking.dispatchNo().isBlank() + || booking.receipt() == null || booking.receipt().source() == null) + return blocked("发车外接货缺少实收及排车依据"); + var receipt = booking.receipt().source(); + if (receipt.pallets() == null || receipt.pallets().isEmpty()) return blocked("发车外接货缺少实收托盘"); + long packages = 0; + for (var pallet : receipt.pallets()) { + var cargo = cargoByPallet.get(pallet.warehousePalletId()); + if (external.put(pallet.warehousePalletId(), booking) != null || cargo == null + || cargo.stream().anyMatch(row -> !booking.dispatchNo().equals(row.dispatchNo()) || !booking.customerId().equals(row.customerId())) + || cargo.stream().mapToLong(FleetExecutionModels.Cargo::packages).sum() != pallet.packages()) + return blocked("外接实收与发车托盘归属未核平"); + packages += pallet.packages(); + } + if (packages != receipt.receivedPackages()) return blocked("外接实收总量与发车托盘未核平"); + } + var shares = new TreeMap(); + for (var entry : cargoByPallet.entrySet()) { + var owners = entry.getValue().stream().map(FleetExecutionModels.Cargo::dispatchNo).distinct().toList(); + if (owners.size() != 1) return blocked("同一实物托盘关联多张排车单,暂不可按托位自动分摊"); + var booking = external.get(entry.getKey()); + String number = booking == null ? owners.get(0) : booking.bookingNo(); + var previous = shares.get(number); + long spaces = measured.get(entry.getKey()).palletSpaces() + (previous == null ? 0 : previous.palletSpaces()); + shares.put(number, new Share(number, booking == null ? "OWN" : "EXTERNAL", owners.get(0), spaces)); + } + String reason = load.pallets().stream().map(FleetExecutionModels.Measurement::deliveryIndex).distinct().count() == 1 + ? null : "各票卸货站不同,暂不可按同路线托位自动分摊"; + return new Basis(load.manifestHash(), List.copyOf(shares.values()), reason); + } + private static Basis blocked(String reason) { return new Basis(null, List.of(), reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java new file mode 100644 index 000000000..cf8164332 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.PageResult; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class FleetTripCostModels { + private FleetTripCostModels() { } + public record Draft(@NotBlank @Pattern(regexp="FUEL|DRIVER|TOLL|PARKING|OTHER") String category, + @NotBlank @Pattern(regexp="CNY|USD") String currency, + @NotNull @DecimalMin("0") @Digits(integer=12,fraction=2) BigDecimal amount, + @NotNull Instant incurredAt, @Size(max=1000) String remark) { } + public record Create(@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotNull @Valid Draft details) { } + public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details, @Size(max=1000) String reason) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 10 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record Cost(String costNo, String tripNo, Draft details, long recordVersion, Instant updatedAt) { } + public record Allocation(String businessNo, String sourceType, String dispatchNo, long palletSpaces, BigDecimal amount) { } + public record Total(String currency, BigDecimal directCost, List allocations, BigDecimal ownCost, BigDecimal externalCost) { } + public record Summary(Long departureVersion, String manifestHash, String allocationMethod, String allocationBlockReason, List totals) { } + public record Listing(PageResult page, Summary summary) { } + public record Snapshot(Cost cost, Summary summary) { } + public record History(long recordVersion, String action, String reason, Instant occurredAt, Snapshot snapshot) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java new file mode 100644 index 000000000..3da0d13db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.ArrayList; + +@Repository +public class FleetTripCostRepository { + record Row(long id, long tripId, String costNo, FleetTripCostModels.Draft details, long version, + String requestHash, LocalDateTime updatedAt) { } + record Amount(String currency, BigDecimal amount) { } + record Departure(long version, String snapshot) { } + record Audit(long version, String action, String reason, Instant occurredAt, String snapshot) { } + private static final RowMapper ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getString("cost_no"), + new FleetTripCostModels.Draft(r.getString("category"), r.getString("currency"), r.getBigDecimal("amount"), + r.getTimestamp("incurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("remark")), + r.getLong("record_version"), r.getString("request_hash"), r.getTimestamp("updated_at").toLocalDateTime()); + private final JdbcTemplate jdbc; + public FleetTripCostRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + Row find(long tripId, String number) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND cost_no=?", ROW, tripId, number).stream().findFirst().orElse(null); + } + Row created(long tripId, String operator, String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND created_by=? AND request_key=?", ROW, tripId, operator, key).stream().findFirst().orElse(null); + } + long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost WHERE trip_id=?", Long.class, tripId); } + List page(long tripId, FleetTripCostModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? ORDER BY incurred_at DESC,id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset()); + } + List totals(long tripId) { + return jdbc.query("SELECT currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id=? GROUP BY currency ORDER BY currency", + (r, ignored) -> new Amount(r.getString("currency"), r.getBigDecimal("total")), tripId); + } + Departure departure(long tripId) { + var rows = jdbc.query("SELECT record_version,snapshot_json FROM ll_fleet_trip_history WHERE trip_id=? AND action='DEPART'", + (r, ignored) -> new Departure(r.getLong("record_version"), r.getString("snapshot_json")), tripId); + if (rows.size() > 1) throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊"); + return rows.isEmpty() ? null : rows.get(0); + } + Map trips(Collection numbers) { + var result = new LinkedHashMap(); + jdbc.query("SELECT trip_no,id FROM ll_fleet_trip WHERE trip_no IN (" + placeholders(numbers.size()) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("trip_no"), r.getLong("id")), numbers.toArray()); + return result; + } + Map departures(Collection ids) { + var result = new LinkedHashMap(); + if (ids.isEmpty()) return result; + jdbc.query("SELECT trip_id,record_version,snapshot_json FROM ll_fleet_trip_history WHERE action='DEPART' AND trip_id IN (" + placeholders(ids.size()) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> { + if (result.put(r.getLong("trip_id"), new Departure(r.getLong("record_version"), r.getString("snapshot_json"))) != null) + throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊"); + }, ids.toArray()); + return result; + } + Map> totals(Collection ids) { + var result = new LinkedHashMap>(); + if (ids.isEmpty()) return result; + jdbc.query("SELECT trip_id,currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id IN (" + placeholders(ids.size()) + ") GROUP BY trip_id,currency ORDER BY trip_id,currency", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.computeIfAbsent(r.getLong("trip_id"), ignored -> new ArrayList<>()) + .add(new Amount(r.getString("currency"), r.getBigDecimal("total"))), ids.toArray()); + return result; + } + private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); } + void insert(Row row, String operator, String key) { + jdbc.update("INSERT INTO ll_fleet_trip_cost(id,trip_id,cost_no,category,currency,amount,incurred_at,remark,record_version,request_key,request_hash,created_by,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + row.id(), row.tripId(), row.costNo(), row.details().category(), row.details().currency(), row.details().amount(), + utc(row.details().incurredAt()), row.details().remark(), row.version(), key, row.requestHash(), operator, operator, row.updatedAt(), row.updatedAt()); + } + int update(Row row, long expected, String operator) { + return jdbc.update("UPDATE ll_fleet_trip_cost SET category=?,currency=?,amount=?,incurred_at=?,remark=?,record_version=?,updated_by=?,updated_at=? WHERE id=? AND record_version=?", + row.details().category(), row.details().currency(), row.details().amount(), utc(row.details().incurredAt()), row.details().remark(), row.version(), operator, row.updatedAt(), row.id(), expected); + } + void audit(long id, Row row, String operator, String action, String reason, String snapshot) { + jdbc.update("INSERT INTO ll_fleet_trip_cost_history(id,cost_id,record_version,action,reason,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?,?)", + id, row.id(), row.version(), action, reason, operator, row.updatedAt(), snapshot); + } + long historyCount(long id) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history WHERE cost_id=?", Long.class, id); } + List history(long id, FleetTripCostModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost_history WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?", + (r, ignored) -> new Audit(r.getLong("record_version"), r.getString("action"), r.getString("reason"), + r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("snapshot_json")), id, query.size(), query.offset()); + } + private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java new file mode 100644 index 000000000..a699c2ce7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.*; + +@Service +public class FleetTripCostService { + private final FleetTripMapper trips; + private final FleetTripCostRepository costs; + private final ObjectMapper json; + public FleetTripCostService(FleetTripMapper trips, FleetTripCostRepository costs, ObjectMapper json) { + this.trips = trips; this.costs = costs; this.json = json; + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public FleetTripCostModels.Listing list(String tripNo, FleetTripCostModels.Query query) { + var trip = required(tripNo, false); + long count = costs.count(trip.getId()); + return new FleetTripCostModels.Listing(new PageResult<>(costs.page(trip.getId(), query).stream().map(row -> response(tripNo, row)).toList(), + query.page(), query.size(), count, pages(count, query.size())), summary(trip)); + } + /** At most 100 distinct trips. Missing trips are omitted; existing trips with no costs have empty totals. */ + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public Map summaries(Collection tripNos) { + if (tripNos == null || tripNos.size() > 100 || tripNos.stream().anyMatch(no -> no == null || no.isBlank())) + throw error(HttpStatus.BAD_REQUEST, "批量成本查询最多100个有效车次号"); + if (tripNos.isEmpty()) return Map.of(); + var numbers = new LinkedHashSet<>(tripNos); + var tripsByNo = costs.trips(numbers); + var departures = costs.departures(tripsByNo.values()); + var amounts = costs.totals(tripsByNo.values()); + var result = new LinkedHashMap(); + for (var number : numbers) { + var id = tripsByNo.get(number); + if (id != null) result.put(number, summary(departures.get(id), amounts.getOrDefault(id, List.of()))); + } + return Collections.unmodifiableMap(result); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult history(String tripNo, String costNo, FleetTripCostModels.Query query) { + var trip = required(tripNo, false); var row = cost(trip.getId(), costNo); long count = costs.historyCount(row.id()); + return new PageResult<>(costs.history(row.id(), query).stream().map(h -> new FleetTripCostModels.History(h.version(), h.action(), h.reason(), h.occurredAt(), + read(h.snapshot(), FleetTripCostModels.Snapshot.class))).toList(), query.page(), query.size(), count, pages(count, query.size())); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public FleetTripCostModels.Cost create(String tripNo, String operator, FleetTripCostModels.Create request) { + var trip = required(tripNo, true); + String hash = digest(write(canonical(request.details()))); + var prior = costs.created(trip.getId(), operator, request.idempotencyKey()); + if (prior != null) { + if (!hash.equals(prior.requestHash())) throw error(HttpStatus.CONFLICT, "请求标识已用于其他成本内容"); + return response(tripNo, prior); + } + validate(request.details()); + var row = new FleetTripCostRepository.Row(IdWorker.getId(), trip.getId(), "TC-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT), + canonical(request.details()), 0, hash, LocalDateTime.now(ZoneOffset.UTC)); + costs.insert(row, operator, request.idempotencyKey()); + audit(trip, row, operator, "CREATE", "登记本趟直接成本"); + return response(tripNo, row); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public FleetTripCostModels.Cost update(String tripNo, String costNo, String operator, FleetTripCostModels.Update request) { + var trip = required(tripNo, true); var prior = cost(trip.getId(), costNo); + if (prior.version() != request.recordVersion()) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试"); + validate(request.details()); + if (canonical(prior.details()).equals(canonical(request.details()))) return response(tripNo, prior); + var row = new FleetTripCostRepository.Row(prior.id(), prior.tripId(), prior.costNo(), canonical(request.details()), prior.version() + 1, prior.requestHash(), LocalDateTime.now(ZoneOffset.UTC)); + if (costs.update(row, prior.version(), operator) != 1) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试"); + audit(trip, row, operator, "UPDATE", request.reason() == null || request.reason().isBlank() ? "更正本趟直接成本" : request.reason().trim()); + return response(tripNo, row); + } + private void audit(FleetTripEntity trip, FleetTripCostRepository.Row row, String operator, String action, String reason) { + costs.audit(IdWorker.getId(), row, operator, action, reason, write(new FleetTripCostModels.Snapshot(response(trip.getTripNo(), row), summary(trip)))); + } + private FleetTripCostModels.Summary summary(FleetTripEntity trip) { + return summary(costs.departure(trip.getId()), costs.totals(trip.getId())); + } + private FleetTripCostModels.Summary summary(FleetTripCostRepository.Departure departure, List amountsByCurrency) { + var basis = FleetTripCostBasis.from(departure == null ? null : read(departure.snapshot(), FleetTripModels.AuditSnapshot.class)); + var totals = new ArrayList(); + for (var total : amountsByCurrency) { + var allocations = new ArrayList(); + BigDecimal own = null, external = null; + if (basis.blockReason() == null) { + var amounts = new HashMap(); + TripCostAllocationPolicy.allocate(total.amount(), total.currency(), basis.shares().stream() + .map(share -> new TripCostAllocationPolicy.Share(share.businessNo(), share.palletSpaces())).toList()) + .forEach(part -> amounts.put(part.bookingNo(), part.amount())); + own = BigDecimal.ZERO.setScale(2); external = BigDecimal.ZERO.setScale(2); + for (var share : basis.shares()) { + var amount = amounts.get(share.businessNo()); + allocations.add(new FleetTripCostModels.Allocation(share.businessNo(), share.sourceType(), share.dispatchNo(), share.palletSpaces(), amount)); + if ("OWN".equals(share.sourceType())) own = own.add(amount); else external = external.add(amount); + } + } + totals.add(new FleetTripCostModels.Total(total.currency(), total.amount(), allocations, own, external)); + } + return new FleetTripCostModels.Summary(departure == null ? null : departure.version(), basis.manifestHash(), + basis.blockReason() == null ? "SAME_ROUTE_PALLET_SPACES" : null, basis.blockReason(), totals); + } + private FleetTripEntity required(String number, boolean lock) { + var trip = lock ? trips.lockByNo(number) : trips.findByNo(number); + if (trip == null) throw error(HttpStatus.NOT_FOUND, "车次不存在"); return trip; + } + private FleetTripCostRepository.Row cost(long tripId, String number) { + var row = costs.find(tripId, number); if (row == null) throw error(HttpStatus.NOT_FOUND, "本车次成本记录不存在"); return row; + } + private static FleetTripCostModels.Cost response(String tripNo, FleetTripCostRepository.Row row) { + return new FleetTripCostModels.Cost(row.costNo(), tripNo, row.details(), row.version(), row.updatedAt().toInstant(ZoneOffset.UTC)); + } + private static void validate(FleetTripCostModels.Draft draft) { + if (draft.incurredAt().isAfter(Instant.now())) throw error(HttpStatus.BAD_REQUEST, "实际费用时间不能晚于当前时间"); + } + private static FleetTripCostModels.Draft canonical(FleetTripCostModels.Draft draft) { + String remark = draft.remark() == null || draft.remark().isBlank() ? null : draft.remark().trim(); + return new FleetTripCostModels.Draft(draft.category(), draft.currency(), draft.amount().stripTrailingZeros(), + draft.incurredAt().truncatedTo(java.time.temporal.ChronoUnit.MILLIS), remark); + } + private String write(Object value) { + try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("成本快照无法保存", error); } + } + private T read(String value, Class type) { + try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException("成本依据无法读取", error); } + } + private static String digest(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static long pages(long count, int size) { return count / size + (count % size == 0 ? 0 : 1); } + private static ResponseStatusException error(HttpStatus status, String text) { return new ResponseStatusException(status, text); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index a6ae434a2..ab0cca722 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -209,7 +209,8 @@ public class FleetTripExecutionService { if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); if (reason == null) reason = loadingReviewBlockReason(trip); if (reason != null) throw conflict(reason); - Instant now = Instant.now(); + // Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result. + Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); trip.setStatus("DEPARTING"); if (trips.updateById(trip) != 1) throw conflict("车次状态已变化"); var results = new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java new file mode 100644 index 000000000..6effa8274 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs") +public class PlatformFleetTripCostController { + private final FleetTripCostService service; + public PlatformFleetTripCostController(FleetTripCostService service) { this.service = service; } + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse list(@PathVariable String tripNo, @Valid FleetTripCostModels.Query query) { + return ApiResponse.success(service.list(tripNo, query)); + } + @GetMapping("/{costNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> history(@PathVariable String tripNo, @PathVariable String costNo, @Valid FleetTripCostModels.Query query) { + return ApiResponse.success(service.history(tripNo, costNo, query)); + } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:cost-create')") + public ApiResponse create(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestBody @Valid FleetTripCostModels.Create request) { + return ApiResponse.success(service.create(tripNo, user.userId().toString(), request)); + } + @PutMapping("/{costNo}") + @PreAuthorize("hasAuthority('platform:fleet:trip:cost-edit')") + public ApiResponse update(@PathVariable String tripNo, @PathVariable String costNo, + @AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid FleetTripCostModels.Update request) { + return ApiResponse.success(service.update(tripNo, costNo, user.userId().toString(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java index 3e6b5616c..720cd2b0f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java @@ -8,8 +8,12 @@ import java.util.List; /** Read projection in the Lastleg schema. Never derives vehicle identity from a mutable plate. */ @Mapper public interface VehicleFulfillmentMapper { - String DEPARTED = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' " + String ORIGINAL_DEPARTURE = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' " + "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})"; + String HANDOVER = "EXISTS (SELECT 1 FROM ll_fleet_trip_handover ho WHERE ho.trip_id=t.id " + + "AND JSON_UNQUOTE(JSON_EXTRACT(ho.snapshot_json,'$.replacement.vehicleId'))=#{vehicleKey} " + + "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)"; + String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")"; String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b " + "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) " + "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")"; @@ -17,7 +21,8 @@ public interface VehicleFulfillmentMapper { String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))"; String CHANGES = " FROM ll_fleet_trip_history h JOIN ll_fleet_trip t ON t.id=h.trip_id " + "JOIN ll_fleet_trip_history p ON p.trip_id=h.trip_id AND p.record_version=h.record_version-1 " - + "WHERE h.action='ASSIGN' AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' " + + "LEFT JOIN ll_fleet_trip_handover ho ON ho.trip_id=h.trip_id AND ho.trip_version=h.record_version " + + "WHERE (h.action='ASSIGN' OR (h.action='HANDOVER' AND ho.id IS NOT NULL)) AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' " + "AND (" + PREVIOUS_VEHICLE + "=#{vehicleKey} OR " + NEXT_VEHICLE + "=#{vehicleKey}) " + "AND (" + PREVIOUS_VEHICLE + "<>" + NEXT_VEHICLE + " OR JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.driverId'))<>JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.driverId')))"; @@ -32,7 +37,8 @@ public interface VehicleFulfillmentMapper { @Param("offset") long offset, @Param("size") int size); @Select("SELECT COUNT(*)" + CHANGES) long countChanges(@Param("vehicleKey") String vehicleKey); - @Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,h.snapshot_json AS after_snapshot" + @Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot," + + "CASE WHEN h.action='HANDOVER' THEN JSON_SET(h.snapshot_json,'$.handover',JSON_EXTRACT(ho.snapshot_json,'$')) ELSE h.snapshot_json END AS after_snapshot" + CHANGES + " ORDER BY h.occurred_at DESC,h.id DESC LIMIT #{size} OFFSET #{offset}") List changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java index 2c8337b0b..82647e42b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java @@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.ZoneOffset; -import java.util.ArrayList; @Service @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) @@ -37,8 +36,9 @@ public class VehicleFulfillmentService { try { JsonNode before = json.readTree(row.beforeSnapshot()).path("trip"), snapshot = json.readTree(row.afterSnapshot()), after = snapshot.path("trip"); if (!before.hasNonNull("vehicleId") || !after.hasNonNull("vehicleId")) throw new IllegalStateException("车辆更换历史缺少身份快照"); - var dispatches = new ArrayList(); - for (JsonNode binding : snapshot.path("dispatches")) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText()); + var dispatches = new java.util.LinkedHashSet(); + JsonNode associated = snapshot.hasNonNull("handover") ? snapshot.path("handover").path("cargo").path("remainingCargo") : snapshot.path("dispatches"); + for (JsonNode binding : associated) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText()); String relationship = before.path("vehicleId").asText().equals(after.path("vehicleId").asText()) ? "DRIVER_CHANGED" : key.equals(before.path("vehicleId").asText()) ? "REPLACED_OUT" : "REPLACED_IN"; return new VehicleFulfillmentModels.Change(row.tripNo(), row.recordVersion(), row.occurredAt().toInstant(ZoneOffset.UTC), row.reason(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 9858946d1..ff7a259e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -17,7 +17,8 @@ public class UnloadCompletedOutboxRepository { EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, - EventTypes.WALLET_COMMAND_REQUESTED); + EventTypes.WALLET_COMMAND_REQUESTED, + EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); @Resource private LastlegEventOutboxMapper mapper; public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 705de1fd8..f9fcca611 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1212,6 +1212,138 @@ CREATE TABLE ll_fleet_trip_history ( CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史'; +CREATE TABLE ll_fleet_trip_operations_record ( + id BIGINT NOT NULL COMMENT '出车实测历史主键', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增', + details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照', + reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键', + request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_operations_version (trip_no,record_version), + UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录'; + +CREATE TABLE ll_fleet_trip_handover ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号', + request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本', + handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间', + snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照', + vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果', + driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果', + created_by VARCHAR(64) NOT NULL COMMENT '记录操作人', + created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_handover_no (handover_no), + UNIQUE KEY uk_fleet_handover_request (trip_id, request_key), + UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version), + KEY idx_fleet_handover_page (trip_id, id), + CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接'; + +CREATE TABLE ll_fleet_finance_source ( + id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键', + record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键', + source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增', + record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本', + operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀', + event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID', + payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version), + UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key), + UNIQUE KEY uk_fleet_finance_source_event (event_id), + CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0) +) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定'; + +CREATE TABLE ll_fleet_quote ( + id BIGINT PRIMARY KEY COMMENT '报价记录主键', + quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号', + kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售', + party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识', + party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照', + origin_city VARCHAR(128) NOT NULL COMMENT '发货城市', + destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码', + valid_from DATE NOT NULL COMMENT '报价生效日期', + valid_to DATE NOT NULL COMMENT '报价失效日期含当天', + status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态', + natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要', + details_json JSON NOT NULL COMMENT '完整报价明细快照', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + UNIQUE KEY uk_fleet_quote_no (quote_no), + UNIQUE KEY uk_fleet_quote_natural (natural_key), + UNIQUE KEY uk_fleet_quote_create (created_by,create_key), + KEY idx_fleet_quote_search (kind,status,valid_from,valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; + +CREATE TABLE ll_fleet_quote_history ( + id BIGINT PRIMARY KEY COMMENT '报价历史主键', + quote_id BIGINT NOT NULL COMMENT '报价记录标识', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + action VARCHAR(24) NOT NULL COMMENT '报价操作类型', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + snapshot_json JSON NOT NULL COMMENT '报价完整版本快照', + UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; + +CREATE TABLE ll_fleet_trip_cost ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号', + category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间', + remark VARCHAR(1000) NULL COMMENT '费用备注', + record_version BIGINT NOT NULL COMMENT '成本记录版本', + request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_no(cost_no), + UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key), + KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id), + CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0), + CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')), + CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款'; + +CREATE TABLE ll_fleet_trip_cost_history ( + id BIGINT NOT NULL COMMENT '内部主键', + cost_id BIGINT NOT NULL COMMENT '成本记录内部引用', + record_version BIGINT NOT NULL COMMENT '成本更正后版本', + action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + reason VARCHAR(1000) NULL COMMENT '登记或更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version), + CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); diff --git a/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml new file mode 100644 index 000000000..c2bf97b16 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml @@ -0,0 +1,290 @@ +openapi: 3.0.3 +info: + title: 车辆在途交接 + version: 1.0.0 + description: 从不可变DEPART及真实派送终态核对剩余货物。交接不重新出库或收费,不重写原始发车。旧车辆和司机分别确认实际任务结束后释放。 +security: + - bearerAuth: [] +paths: + /api/pda/v1/last-leg/dispatches/{dispatchNo}/handover-status: + get: + description: lastleg:pda:operate及ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR;只读,仓库来自Token。只返回所选排车的原发车资源、逐派送当前资源及涉及该排车的最近交接,不返回其他客户货物或内部ID。不提供PDA交接确认写接口。 + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + responses: + '200': + description: 当前排车运输及交接状态 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + properties: + dispatchNo: { type: string } + tripNo: { type: string } + tripStatus: { type: string, enum: [IN_TRANSIT, COMPLETED] } + tripRecordVersion: { type: string } + originalDeparture: { $ref: '#/components/schemas/Transport' } + deliveries: + type: array + items: + type: object + properties: + deliveryNo: { type: string } + status: { type: string } + packages: { type: integer } + terminalAt: { type: string, format: date-time, nullable: true } + transport: { $ref: '#/components/schemas/Transport' } + latestHandover: + type: object + nullable: true + properties: + handoverNo: { type: string } + handedOverAt: { type: string, format: date-time } + location: { type: string } + previous: { $ref: '#/components/schemas/Transport' } + replacement: { $ref: '#/components/schemas/Transport' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers: + parameters: + - $ref: '#/components/parameters/TripNo' + get: + description: platform:fleet:trip:list;按车次分页读取交接及原资源任务结束记录。 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + responses: + '200': + description: 业务响应 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/Handover' } } + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + pages: { type: integer } + post: + description: platform:fleet:trip:handover;同车次请求键唯一,异内容拒绝;先锁车次及新资源,再锁原客户执行单复核。原资源仍占用,车次保持IN_TRANSIT,仅未完成派送更新资源。失败整体回滚。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [selection, cargoHash, location, idempotencyKey] + properties: + selection: { $ref: '#/components/schemas/Selection' } + cargoHash: { type: string, pattern: '^[0-9a-f]{64}$' } + location: { type: string, minLength: 1, maxLength: 500 } + reason: { type: string, maxLength: 1000 } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + responses: + '200': { $ref: '#/components/responses/Handover' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers/preview: + parameters: + - $ref: '#/components/parameters/TripNo' + post: + description: platform:fleet:trip:list;只核对不写入。资格、容量、占用和派送事实变化使cargoHash失效。已签收后另退货使用原签收事实,未签收退回必须有RETURN_COMPLETED证据。部分同托仍用原整托实测,不按箱数比例猜重量体积。 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Selection' } + responses: + '200': + description: 交接预览 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + required: [tripNo, recordVersion, cargoHash, previous, replacement, capacity, cargo, driverQualification] + properties: + tripNo: { type: string } + recordVersion: { type: integer, format: int64 } + cargoHash: { type: string } + previous: { $ref: '#/components/schemas/Resource' } + replacement: { $ref: '#/components/schemas/Resource' } + capacity: { $ref: '#/components/schemas/Load' } + cargo: { $ref: '#/components/schemas/Inspection' } + driverQualification: + type: object + properties: + canDispatch: { type: boolean } + expiringSoon: { type: boolean } + reason: { type: string } + licenseExpiresOn: { type: string, format: date, nullable: true } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers/{handoverNo}/resources/release: + parameters: + - $ref: '#/components/parameters/TripNo' + - { name: handoverNo, in: path, required: true, schema: { type: string } } + post: + description: platform:fleet:trip:release;分别结束被接替车辆或司机任务,可早于整车货物结案。未变化资源仍由原车次结案后释放。原请求重放不操作后续占用,车辆状态与结束记录同事务提交。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, resourceKind, actualEndAt, location, idempotencyKey] + properties: + recordVersion: { type: integer, format: int64, minimum: 0, description: 交接记录版本 } + resourceKind: { type: string, enum: [VEHICLE, DRIVER] } + actualEndAt: { type: string, format: date-time } + location: { type: string, minLength: 1, maxLength: 500 } + vehicleStatus: { type: string, nullable: true, enum: [IN_SERVICE, MAINTENANCE, INACTIVE, RETURNED], description: 结束原车辆必填;司机不得提交 } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + responses: + '200': { $ref: '#/components/responses/Handover' } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + TripNo: { name: tripNo, in: path, required: true, schema: { type: string } } + responses: + Handover: + description: 已保存的交接事实及资源结束状态 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Handover' } + schemas: + Transport: + type: object + properties: + vehiclePlate: { type: string, nullable: true } + driverName: { type: string, nullable: true } + driverPhone: { type: string, nullable: true } + Envelope: + type: object + required: [code] + properties: + code: { type: string, example: '200' } + message: { type: string } + Selection: + type: object + required: [recordVersion, vehicleId, driverId, expectedDriverEndAt, handedOverAt] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + vehicleId: { type: string, pattern: '^[1-9][0-9]*$' } + driverId: { type: string, pattern: '^[1-9][0-9]*$' } + expectedDriverEndAt: { type: string, format: date-time } + handedOverAt: { type: string, format: date-time } + Resource: + type: object + properties: + vehicleId: { type: string } + vehiclePlate: { type: string, nullable: true } + driverId: { type: string } + driverName: { type: string, nullable: true } + driverPhone: { type: string, nullable: true } + Load: + type: object + properties: + palletSpaces: { type: integer } + weightKg: { type: number } + volumeCbm: { type: number } + Cargo: + type: object + properties: + customerId: { type: string, description: 隔离键,不作客户名称展示 } + dispatchNo: { type: string } + itemId: { type: string, description: 内部核对键,不作业务编号展示 } + palletId: { type: string, description: 内部核对键,不作托盘码展示 } + destinationKey: { type: string, description: 匹配键,私人地址摘要不可作地址展示 } + packages: { type: integer } + Delivery: + type: object + properties: + customerId: { type: string } + dispatchNo: { type: string } + deliveryNo: { type: string } + destinationKey: { type: string } + packages: { type: integer } + terminalAt: { type: string, format: date-time, nullable: true } + Inspection: + type: object + properties: + remainingCargo: { type: array, items: { $ref: '#/components/schemas/Cargo' } } + completedDeliveries: { type: array, items: { $ref: '#/components/schemas/Delivery' } } + remainingPalletIds: { type: array, uniqueItems: true, items: { type: string } } + occupied: { $ref: '#/components/schemas/Load' } + Snapshot: + type: object + properties: + previous: { $ref: '#/components/schemas/Resource' } + replacement: { $ref: '#/components/schemas/Resource' } + handedOverAt: { type: string, format: date-time } + location: { type: string } + reason: { type: string } + expectedDriverEndAt: { type: string, format: date-time } + capacity: { $ref: '#/components/schemas/Load' } + cargo: { $ref: '#/components/schemas/Inspection' } + executionEvidence: + type: array + items: + type: object + properties: + dispatchNo: { type: string } + customerId: { type: string } + warehouseId: { type: string } + shippedPackages: { type: integer } + deliveries: + type: array + items: + type: object + properties: + cargo: { $ref: '#/components/schemas/Delivery' } + status: { type: string } + recordVersion: { type: integer, format: int64 } + transport: { $ref: '#/components/schemas/Transport' } + operatorId: { type: string, description: 审计身份,不作姓名展示 } + carriedDeliveries: + type: array + description: 当次实际交接的未完成派送身份,用于车辆运输区段查询 + items: + type: object + properties: + customerId: { type: string } + deliveryNo: { type: string } + recordedAt: { type: string, format: date-time } + Released: + type: object + nullable: true + properties: + idempotencyKey: { type: string } + requestHash: { type: string } + actualEndAt: { type: string, format: date-time } + location: { type: string } + vehicleStatus: { type: string, nullable: true } + operatorId: { type: string } + recordedAt: { type: string, format: date-time } + Handover: + type: object + properties: + handoverNo: { type: string } + tripNo: { type: string } + recordVersion: { type: integer, format: int64 } + snapshot: { $ref: '#/components/schemas/Snapshot' } + vehicleRelease: { $ref: '#/components/schemas/Released' } + driverRelease: { $ref: '#/components/schemas/Released' } diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml new file mode 100644 index 000000000..4f43b2d5c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -0,0 +1,928 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "真实出车与自有外接运营统计", + "version": "1.0.0", + "description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;财务未查询明确NOT_QUERIED;成本只通过FleetTripCostService.summaries(Collection)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/api/platform/v1/last-leg/fleet/operations": { + "get": { + "description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "warehouseId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "sourceType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "OWN", + "EXTERNAL" + ] + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "IN_TRANSIT", + "COMPLETED" + ] + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "page": { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "records", + "page", + "size", + "total", + "pages" + ] + }, + "summary": { + "$ref": "#/components/schemas/Summary" + }, + "charts": { + "$ref": "#/components/schemas/Charts" + } + }, + "required": [ + "page", + "summary", + "charts" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/export": { + "get": { + "description": "platform:fleet:operations:export;同筛选同事实来源的CSV", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "warehouseId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "sourceType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "OWN", + "EXTERNAL" + ] + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "IN_TRANSIT", + "COMPLETED" + ] + } + } + ], + "responses": { + "200": { + "description": "UTF-8 BOM CSV", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:operations:list;仅实际已发车车次;尚未登记返回版本0和空值,不伪造零里程/油耗", + "responses": { + "200": { + "description": "业务响应,成功code为200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/MeasuredRecord" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "put": { + "description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "details": { + "$ref": "#/components/schemas/Measured" + }, + "reason": { + "type": "string", + "maxLength": 1000, + "nullable": true + } + }, + "required": [ + "recordVersion", + "idempotencyKey", + "details", + "reason" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应,成功code为200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/MeasuredRecord" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/history": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:operations:list;实测更正历史,按版本倒序分页", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "业务响应,成功code为200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MeasuredHistory" + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "records", + "page", + "size", + "total", + "pages" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "Money": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "amount": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "currency", + "amount" + ] + }, + "Row": { + "type": "object", + "properties": { + "tripNo": { + "type": "string" + }, + "status": { + "type": "string" + }, + "vehiclePlate": { + "type": "string", + "nullable": true + }, + "driverName": { + "type": "string", + "nullable": true + }, + "warehouseName": { + "type": "string", + "nullable": true + }, + "departedAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "destinations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Destination" + } + }, + "physicalPallets": { + "type": "integer", + "minimum": 0 + }, + "palletSpaces": { + "type": "integer", + "minimum": 0 + }, + "ownPackages": { + "type": "integer", + "minimum": 0 + }, + "externalPackages": { + "type": "integer", + "minimum": 0 + }, + "externalAgreedAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Money" + } + }, + "financeStatus": { + "type": "string", + "enum": [ + "NOT_QUERIED" + ] + }, + "costStatus": { + "type": "string", + "enum": [ + "FORBIDDEN", + "NOT_RECORDED", + "AVAILABLE" + ] + }, + "externalPalletRates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PalletRate" + } + }, + "measured": { + "allOf": [ + { + "$ref": "#/components/schemas/Measured" + } + ], + "nullable": true + }, + "costs": { + "allOf": [ + { + "$ref": "./fleet-trip-costs-v1.yaml#/components/schemas/Summary" + } + ], + "nullable": true + } + }, + "required": [ + "tripNo", + "status", + "vehiclePlate", + "driverName", + "warehouseName", + "departedAt", + "completedAt", + "destinations", + "physicalPallets", + "palletSpaces", + "ownPackages", + "externalPackages", + "externalAgreedAmounts", + "financeStatus", + "costStatus", + "externalPalletRates", + "measured", + "costs" + ] + }, + "Summary": { + "type": "object", + "properties": { + "trips": { + "type": "integer", + "minimum": 0 + }, + "inTransit": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + }, + "physicalPallets": { + "type": "integer", + "minimum": 0 + }, + "palletSpaces": { + "type": "integer", + "minimum": 0 + }, + "ownPackages": { + "type": "integer", + "minimum": 0 + }, + "externalPackages": { + "type": "integer", + "minimum": 0 + }, + "externalAgreedAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Money" + } + }, + "costs": { + "$ref": "#/components/schemas/CostSummary" + } + }, + "required": [ + "trips", + "inTransit", + "completed", + "physicalPallets", + "palletSpaces", + "ownPackages", + "externalPackages", + "externalAgreedAmounts", + "costs" + ] + }, + "Measured": { + "type": "object", + "properties": { + "actualDistanceMiles": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "actualFuelLitres": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "remark": { + "type": "string", + "nullable": true, + "maxLength": 1000 + } + }, + "required": [ + "actualDistanceMiles", + "actualFuelLitres", + "remark" + ] + }, + "Destination": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "key", + "label" + ] + }, + "PalletRate": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "pricedPalletSpaces": { + "type": "integer", + "minimum": 0 + }, + "agreedAmount": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "currency", + "pricedPalletSpaces", + "agreedAmount" + ] + }, + "Series": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "label", + "value" + ] + } + } + }, + "required": [ + "label", + "unit", + "points" + ] + }, + "Charts": { + "type": "object", + "properties": { + "physicalPallets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + }, + "destinationTrips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + }, + "externalAgreedAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + }, + "externalPalletRates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Series" + } + } + }, + "required": [ + "physicalPallets", + "destinationTrips", + "externalAgreedAmounts", + "externalPalletRates" + ] + }, + "MeasuredRecord": { + "type": "object", + "properties": { + "tripNo": { + "type": "string" + }, + "recordVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "details": { + "$ref": "#/components/schemas/Measured" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "tripNo", + "recordVersion", + "details", + "updatedAt" + ] + }, + "MeasuredHistory": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "reason": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "details": { + "$ref": "#/components/schemas/Measured" + } + }, + "required": [ + "recordVersion", + "reason", + "occurredAt", + "details" + ] + }, + "CostTotal": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "directCost": { + "type": "number", + "minimum": 0 + }, + "ownCost": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "externalCost": { + "type": "number", + "minimum": 0, + "nullable": true + } + }, + "required": [ + "currency", + "directCost", + "ownCost", + "externalCost" + ] + }, + "CostSummary": { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "unrecordedTrips": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "unallocatedTrips": { + "type": "integer", + "minimum": 0, + "nullable": true + }, + "totals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CostTotal" + } + } + }, + "required": [ + "visible", + "unrecordedTrips", + "unallocatedTrips", + "totals" + ] + } + } + }, + "x-integration-database": { + "owner": "总控负责集成模块基线与海外统一初始化SQL,专项不执行SQL。新增权限platform:fleet:operations:edit。", + "ddl": "CREATE TABLE ll_fleet_trip_operations_record (\n id BIGINT NOT NULL COMMENT '出车实测历史主键',\n trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',\n record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增',\n details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照',\n reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明',\n operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识',\n request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键',\n request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要',\n occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC',\n PRIMARY KEY (id),\n UNIQUE KEY uk_fleet_operations_version (trip_no,record_version),\n UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录';" + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml new file mode 100644 index 000000000..d98691fea --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-quotes-v1.yaml @@ -0,0 +1,887 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "车辆采购与外接销售报价库", + "version": "1.0.0", + "description": "采购价关联公共TRUCKING+OVERSEAS有效供应商,销售价关联有效且具备下单资格的客户。报价仅为价格库,不写入成本/应收/应付,不自动覆盖车次成交快照。整车自提与整车派送按单,散板派送按托。日期为路线业务日(含失效当天),未自动匹配成交;同维度同有效期禁止重复,重叠有效期须由运营明确选择具体报价版本。CSV导入界面解析后提交结构化行,整批原子写入;不支持部分成功。查询/导出最多5000条,导入最多200行。历史接口仅返回最新200版。" + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/api/platform/v1/last-leg/fleet/quotes": { + "get": { + "description": "platform:fleet:quote:list;包含详情只读", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "PURCHASE", + "SALE" + ] + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + { + "name": "effectiveOn", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Quote" + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "records", + "page", + "size", + "total", + "pages" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "post": { + "description": "platform:fleet:quote:create;原操作人+幂等键唯一,异内容拒绝。目录校验失败不写入。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "details": { + "$ref": "#/components/schemas/Draft" + } + }, + "required": [ + "idempotencyKey", + "details" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/{quoteNo}": { + "parameters": [ + { + "name": "quoteNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:quote:list", + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "put": { + "description": "platform:fleet:quote:edit;行锁+版本,更正保留旧版,用途不可变", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "details": { + "$ref": "#/components/schemas/Draft" + } + }, + "required": [ + "recordVersion", + "details" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + }, + "delete": { + "description": "platform:fleet:quote:delete;保存DELETED历史,退出报价库;不删除已成交业务快照", + "parameters": [ + { + "name": "recordVersion", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/{quoteNo}/status": { + "parameters": [ + { + "name": "quoteNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "patch": { + "description": "platform:fleet:quote:status;启用时重新校验目录", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + "required": [ + "recordVersion", + "status" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/{quoteNo}/history": { + "parameters": [ + { + "name": "quoteNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "description": "platform:fleet:quote:list;最新200个不可变版本", + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/History" + } + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/customer-options": { + "get": { + "description": "platform:fleet:quote:list;复用IAM有效客户目录。供应商候选由Admin调用现有/platform/v1/suppliers,须platform:supplier:list,非此接口扩权。", + "parameters": [ + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + } + ], + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "contactName": { + "type": "string", + "nullable": true + }, + "contactPhone": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "code", + "name", + "contactName", + "contactPhone" + ] + } + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/import": { + "post": { + "description": "platform:fleet:quote:import;最多200行,所有行与审计同事务提交;原批次键的行数或内容变化拒绝", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 100 + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "$ref": "#/components/schemas/Draft" + } + } + }, + "required": [ + "idempotencyKey", + "rows" + ] + } + } + } + }, + "responses": { + "200": { + "description": "业务响应;成功code为200,业务失败不代表写入成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "importedRows": { + "type": "integer", + "minimum": 0 + }, + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Quote" + } + } + }, + "required": [ + "importedRows", + "records" + ] + } + }, + "required": [ + "code", + "message", + "data" + ] + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/quotes/export": { + "get": { + "description": "platform:fleet:quote:export;全筛选CSV,最多5000行,公式注入转义,空报价保留空值,币种不混合", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "PURCHASE", + "SALE" + ] + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + } + }, + { + "name": "effectiveOn", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "UTF-8 BOM CSV", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "Draft": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "PURCHASE", + "SALE" + ] + }, + "partyId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "originCity": { + "type": "string", + "maxLength": 128 + }, + "destinationCode": { + "type": "string", + "maxLength": 64 + }, + "distanceMiles": { + "type": "number", + "minimum": 0, + "multipleOf": 0.1 + }, + "currency": { + "type": "string", + "enum": [ + "USD", + "CNY" + ] + }, + "selfPickupPrice": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "fullDeliveryPrice": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "looseDeliveryPrice": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "validFrom": { + "type": "string", + "format": "date" + }, + "validTo": { + "type": "string", + "format": "date" + }, + "remark": { + "type": "string", + "nullable": true, + "maxLength": 1000 + } + }, + "required": [ + "kind", + "partyId", + "originCity", + "destinationCode", + "distanceMiles", + "currency", + "validFrom", + "validTo" + ] + }, + "Quote": { + "type": "object", + "properties": { + "quoteNo": { + "type": "string" + }, + "details": { + "$ref": "#/components/schemas/Draft" + }, + "partyName": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "INACTIVE", + "DELETED" + ] + }, + "recordVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "quoteNo", + "details", + "partyName", + "status", + "recordVersion", + "updatedAt" + ] + }, + "History": { + "type": "object", + "properties": { + "recordVersion": { + "type": "integer", + "minimum": 0 + }, + "action": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "snapshot": { + "$ref": "#/components/schemas/Quote" + } + }, + "required": [ + "recordVersion", + "action", + "occurredAt", + "snapshot" + ] + } + } + }, + "x-integration-database": { + "owner": "总控集成:lastleg.sql及海外统一初始化SQL;本文件仅为字段与索引清单,不执行SQL", + "tables": { + "ll_fleet_quote": { + "comment": "车辆采购与外接销售报价", + "columns": { + "id": "BIGINT PRIMARY KEY COMMENT '报价记录主键'", + "quote_no": "VARCHAR(64) NOT NULL COMMENT '报价业务编号'", + "kind": "VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售'", + "party_id": "BIGINT NOT NULL COMMENT '供应商或客户主档标识'", + "party_name": "VARCHAR(160) NOT NULL COMMENT '往来单位名称快照'", + "origin_city": "VARCHAR(128) NOT NULL COMMENT '发货城市'", + "destination_code": "VARCHAR(64) NOT NULL COMMENT '目的仓业务编码'", + "valid_from": "DATE NOT NULL COMMENT '报价生效日期'", + "valid_to": "DATE NOT NULL COMMENT '报价失效日期含当天'", + "status": "VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态'", + "natural_key": "CHAR(64) NOT NULL COMMENT '报价组合去重摘要'", + "details_json": "JSON NOT NULL COMMENT '完整报价明细快照'", + "record_version": "BIGINT NOT NULL COMMENT '报价记录版本'", + "create_key": "VARCHAR(128) NOT NULL COMMENT '创建幂等键'", + "create_hash": "CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要'", + "created_by": "VARCHAR(64) NOT NULL COMMENT '创建操作人标识'", + "created_at": "DATETIME(3) NOT NULL COMMENT '创建时间UTC'", + "updated_at": "DATETIME(3) NOT NULL COMMENT '更新时间UTC'" + }, + "indexes": [ + "UNIQUE KEY uk_fleet_quote_no (quote_no)", + "UNIQUE KEY uk_fleet_quote_natural (natural_key)", + "UNIQUE KEY uk_fleet_quote_create (created_by,create_key)", + "KEY idx_fleet_quote_search (kind,status,valid_from,valid_to)" + ] + }, + "ll_fleet_quote_history": { + "comment": "车辆报价不可变版本历史", + "columns": { + "id": "BIGINT PRIMARY KEY COMMENT '报价历史主键'", + "quote_id": "BIGINT NOT NULL COMMENT '报价记录标识'", + "record_version": "BIGINT NOT NULL COMMENT '报价记录版本'", + "action": "VARCHAR(24) NOT NULL COMMENT '报价操作类型'", + "operator_id": "VARCHAR(64) NOT NULL COMMENT '操作人标识'", + "occurred_at": "DATETIME(3) NOT NULL COMMENT '操作时间UTC'", + "snapshot_json": "JSON NOT NULL COMMENT '报价完整版本快照'" + }, + "indexes": [ + "UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version)" + ] + } + } + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml new file mode 100644 index 000000000..cd4b1f7b2 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-trip-costs-v1.yaml @@ -0,0 +1,183 @@ +openapi: 3.0.3 +info: + title: 车次直接运营成本 + version: 1.0.0 + description: 运营登记与分摊,不生成客户账单、财务应付或付款事实。不同币种分别汇总;仅从不可变DEPART实物装载和BOUND外接实收形成权重。DRAFT/CANCELLED预订不参与,其他未完成关联的有效预订阻止自动分摊,不得归为自有货。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/trips/{tripNo}/costs: + parameters: + - $ref: '#/components/parameters/TripNo' + get: + description: platform:fleet:trip:list;分页记录和整趟汇总使用同一只读快照。没有登记成本时totals为空,不显示为已知零成本。 + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + responses: + '200': + description: 业务响应,成功code为200 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Listing' } + post: + description: platform:fleet:trip:cost-create;按车次和操作人锁定创建幂等键,异载荷同键拒绝。金额末尾零与空白备注按同一内容核对;成本与当时汇总历史同事务保存。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [idempotencyKey, details] + properties: + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + details: { $ref: '#/components/schemas/Draft' } + responses: + '200': { $ref: '#/components/responses/Cost' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/costs/{costNo}: + parameters: + - $ref: '#/components/parameters/TripNo' + - $ref: '#/components/parameters/CostNo' + put: + description: platform:fleet:trip:cost-edit;核对成本版本与车次归属,更正保留旧记录及当时分摊快照。原因选填,缺省使用标准动作说明。内容未变化返回原版本,不追加历史;旧版本即使内容相同仍拒绝。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, details] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + details: { $ref: '#/components/schemas/Draft' } + reason: { type: string, maxLength: 1000 } + responses: + '200': { $ref: '#/components/responses/Cost' } + /api/platform/v1/last-leg/fleet/trips/{tripNo}/costs/{costNo}/history: + parameters: + - $ref: '#/components/parameters/TripNo' + - $ref: '#/components/parameters/CostNo' + get: + description: platform:fleet:trip:list;只返回指定车次成本的不可变更正历史,金额为当时原始币种。 + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + responses: + '200': + description: 历史分页,data包含records/page/size/total/pages + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + properties: + records: { type: array, items: { $ref: '#/components/schemas/History' } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + TripNo: { name: tripNo, in: path, required: true, schema: { type: string } } + CostNo: { name: costNo, in: path, required: true, schema: { type: string } } + Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 10 } } + responses: + Cost: + description: 业务响应;A0404归属不存在,A0409版本或幂等冲突,A0422输入校验,实际发生时间不可为将来 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Cost' } + schemas: + Envelope: + type: object + properties: + code: { type: string } + message: { type: string } + requestId: { type: string } + traceId: { type: string } + Page: + type: object + properties: + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + Draft: + type: object + required: [category, currency, amount, incurredAt] + properties: + category: { type: string, enum: [FUEL, DRIVER, TOLL, PARKING, OTHER] } + currency: { type: string, enum: [CNY, USD] } + amount: { type: number, minimum: 0, maximum: 999999999999.99, multipleOf: 0.01 } + incurredAt: { type: string, format: date-time, description: 实际发生时间,统一按毫秒精度保存和核对 } + remark: { type: string, nullable: true, maxLength: 1000 } + Cost: + type: object + properties: + costNo: { type: string } + tripNo: { type: string } + details: { $ref: '#/components/schemas/Draft' } + recordVersion: { type: integer, format: int64 } + updatedAt: { type: string, format: date-time } + Allocation: + type: object + properties: + businessNo: { type: string, description: 自有货排车单号或外接预订业务号 } + sourceType: { type: string, enum: [OWN, EXTERNAL] } + dispatchNo: { type: string } + palletSpaces: { type: integer, format: int64 } + amount: { type: number } + Total: + type: object + properties: + currency: { type: string } + directCost: { type: number } + allocations: { type: array, items: { $ref: '#/components/schemas/Allocation' } } + ownCost: { type: number, nullable: true } + externalCost: { type: number, nullable: true } + Summary: + type: object + properties: + departureVersion: { type: integer, format: int64, nullable: true } + manifestHash: { type: string, nullable: true } + allocationMethod: { type: string, nullable: true, enum: [SAME_ROUTE_PALLET_SPACES] } + allocationBlockReason: { type: string, nullable: true } + totals: { type: array, items: { $ref: '#/components/schemas/Total' } } + Listing: + type: object + properties: + page: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + properties: + records: { type: array, items: { $ref: '#/components/schemas/Cost' } } + summary: { $ref: '#/components/schemas/Summary' } + Snapshot: + type: object + properties: + cost: { $ref: '#/components/schemas/Cost' } + summary: { $ref: '#/components/schemas/Summary' } + History: + type: object + properties: + recordVersion: { type: integer, format: int64 } + action: { type: string, enum: [CREATE, UPDATE] } + reason: { type: string } + occurredAt: { type: string, format: date-time } + snapshot: { $ref: '#/components/schemas/Snapshot' } diff --git a/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml index 2387545f1..6f06ac8c5 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml @@ -4,9 +4,9 @@ info: version: 1.0.0 description: >- 平台按稳定车辆ID读取Lastleg本库履约事实,不按当前车牌文本反查,不修改单据或历史。 - 计划中的派送归当前分配车辆;实际发车后按不可变DEPART历史中的车辆身份归属。 + 计划中的派送归当前分配车辆;实际发车后按不可变DEPART及在途交接快照中的车辆身份归属。接替车辆仅关联交接时未完成且属于原客户的派送。 关联排车和派送时同时匹配客户归属,不返回财务、附件、联系人或内部数据库主键。 - 更换记录依据相邻车次版本的ASSIGN快照,首次分配和资源未变化的操作不算更换。 + 更换记录依据相邻车次版本的ASSIGN或HANDOVER快照;在途交接仅列实际剩余货物涉及的排车单,首次分配和资源未变化的操作不算更换。 旧车和新车均能读取同一更换事实;仅换司机也保留。返回历史车牌/姓名,不用当前主档覆盖。 不生成原型示例记录;没有正式车次关联的旧派送不通过车牌猜测归属。 security: [{bearerAuth: []}] diff --git a/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml index 6ee0ea42c..4c100bad1 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-vehicle-records-v1.yaml @@ -10,10 +10,70 @@ info: 照片最多20张,每张非空JPG/PNG且最多10MiB;File按平台0、FLEET_VEHICLE_RECORD_PHOTO和记录内部主键复合归属,验证内容签名。 维修和救援项目、地点独立保存;处理中或已完成须有处理人,异常须有明确分类。 费用只是运维申报金额,填金额则必须填有效币种;未填金额和零金额有区别,不生成应付或付款。 - 维修/救援paymentStatus当前固定UNLINKED表示未关联财务,不得由processingStatus推定为已付款,其他类型为空。 + 运维响应paymentStatus为空,不保存可变财务结论;维修/救援通过finance-status批量读取公司财务状态,不得由processingStatus推定付款。 + 显式提交公司成本时取已保存申报金额、服务端供应商与月份汇率,来源映射及Outbox同事务保存;不会自动入账或付款。 + 已提交费用须先在Finance作废才可修改费用项目/金额/币种或重新提交;处理进度、地点和处理人仍可独立更新。 不提供物理删除;编辑与上传历史同事务不可变保存。本接口不提供财务结算或车辆占用/状态自动变更。 security: [{bearerAuth: []}] paths: + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/finance-status: + parameters: [{$ref: '#/components/parameters/Vehicle'}] + post: + description: platform:overseas-master-data:vehicle:list;校验本车维修/救援记录,最多100条批量读取,不逐行调用Finance;依赖失败明确返回错误,不能伪造未关联或付款状态 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordNos] + properties: + recordNos: {type: array, minItems: 1, maxItems: 100, items: {type: string, minLength: 1, maxLength: 64}} + responses: + '200': + description: ApiResponse;data为每条请求记录的状态;Finance接收状态PENDING为待入账,APPROVED为已入账未付,不等于付款 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/FinanceStatus'}} + /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records/{recordNo}/finance: + parameters: + - {$ref: '#/components/parameters/Vehicle'} + - {name: recordNo, in: path, required: true, schema: {type: string}} + post: + description: platform:overseas-master-data:vehicle:record-finance-submit;不复用普通运维编辑权限。前端选择供应商候选另需platform:supplier:list。首次提交或Finance已作废后才可登记新来源版本,未知结果保留原键重试 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, supplierId, businessDate, idempotencyKey] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + supplierId: {type: integer, format: int64, minimum: 1} + businessDate: {type: string, format: date, description: 明确选择实际费用业务日期,用于获取业务月份有效汇率} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + responses: + '200': + description: ApiResponse;仅表示提交请求已登记,PENDING_FINANCE不是已入账或付款 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + properties: + recordNo: {type: string} + sourceVersion: {type: integer, format: int64} + submittedRecordVersion: {type: integer, format: int64} + replayed: {type: boolean} + status: {type: string, enum: [PENDING_FINANCE]} /api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records: parameters: [{$ref: '#/components/parameters/Vehicle'}] get: @@ -172,4 +232,12 @@ components: recordVersion: {type: integer, format: int64, minimum: 0} updatedBy: {type: string} updatedAt: {type: string, format: date-time} - paymentStatus: {type: string, nullable: true, readOnly: true, enum: [UNLINKED], description: 维修/救援未关联财务时为UNLINKED,其余为空;不可用处理完成推定已付款} + paymentStatus: {type: string, nullable: true, readOnly: true, description: 运维快照不记录可变财务状态,当前为空;读取finance-status获取财务事实} + FinanceStatus: + type: object + properties: + recordNo: {type: string} + status: {type: string, enum: [UNLINKED, PENDING_FINANCE, FINANCE_ATTENTION, PENDING, APPROVED, PARTIALLY_PAID, PAID, VOIDED]} + sourceVersion: {type: integer, format: int64, nullable: true} + submittedRecordVersion: {type: integer, format: int64, nullable: true} + finance: {type: object, nullable: true, description: Finance公司费用Cost完整只读快照;来源版本及内容须与本地提交一致;未接收不虚构金额,具体结构见finance-vehicle-costs-v1.yaml的Cost} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java index 060d7b1ee..c3ddb0af8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java @@ -34,6 +34,27 @@ class DirectEventClientTest { verifyNoInteractions(firstleg); } + @Test void companyVehicleCostsUseTheirOwnFinanceEndpoint() { + var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); + var ack = ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + when(finance.vehicleCost("token", event)).thenReturn(ack); + assertThat(client.receive("token", event)).isSameAs(ack); + verify(finance).vehicleCost("token", event); + verifyNoMoreInteractions(finance); + verifyNoInteractions(firstleg); + } + + @Test void companyCostValidationConflictsArePermanentButDependencyFailuresRetry() { + var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); + for (String code : new String[]{"FIN_400", "FIN_404", "FIN_409", "FIN_503"}) { + when(finance.vehicleCost("token", event)).thenReturn(new ApiResponse<>(code, "rejected", null, null, null, Instant.now())); + assertThatThrownBy(() -> client.receive("token", event)) + .isInstanceOf(com.baiyue.common.web.EventDeliveryFailure.class) + .satisfies(error -> assertThat(com.baiyue.common.web.EventDeliveryFailure.isPermanent((Exception) error)).isEqualTo(!"FIN_503".equals(code))); + } + verifyNoInteractions(firstleg); + } + @Test void fulfillmentEventsAreSentOnlyToFirstleg() { for (String type : new String[]{EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { @@ -57,6 +78,8 @@ class DirectEventClientTest { EventTypes.WALLET_COMMAND_REQUESTED); assertEndpoint(FinanceBusinessEventClient.class, "fee", "FINANCE_SERVICE_URL", "finance", EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + assertEndpoint(FinanceBusinessEventClient.class, "vehicleCost", "FINANCE_SERVICE_URL", "finance", + EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); } private void assertEndpoint(Class type, String method, String env, String property, String eventType) throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java new file mode 100644 index 000000000..e0019d4bc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.financefee.fleet; + +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCareFinanceAdapterTest { + @Test void publicationCannotStartItsOwnTransactionAndConversionDelegatesToTheFeeOwner() { + var converter = mock(OverseasFeeCurrencyConverter.class); + var outbox = mock(LastlegEventOutboxMapper.class); + var jdbc = mock(JdbcTemplate.class); + var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-adapter-" + UUID.randomUUID(), "sa", ""); + var adapter = new VehicleCareFinanceAdapter(converter, outbox, jdbc, new ObjectMapper()); + var proxy = new ProxyFactory(adapter); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + var gateway = (VehicleCareFinanceGateway) proxy.getProxy(); + assertThatThrownBy(() -> gateway.publish(null)).isInstanceOf(IllegalTransactionStateException.class); + verifyNoInteractions(outbox); + var date = LocalDate.of(2026, 1, 1); + when(converter.convert("USD", BigDecimal.TEN, date)).thenReturn(new OverseasFeeCurrencyConverter.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00"))); + assertThat(gateway.convert("USD", BigDecimal.TEN, date)).isEqualTo(new VehicleCareFinanceGateway.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00"))); + verify(converter).convert("USD", BigDecimal.TEN, date); + } + @Test void deliveryStatusReadsAreBoundedAndKeepActualOutboxState() { + var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-delivery-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_event_outbox(event_key VARCHAR(128),status VARCHAR(32))"); + jdbc.update("INSERT INTO ll_event_outbox VALUES('one','PENDING'),('two','DEAD'),('other','PUBLISHED')"); + var adapter = new VehicleCareFinanceAdapter(mock(OverseasFeeCurrencyConverter.class), mock(LastlegEventOutboxMapper.class), jdbc, new ObjectMapper()); + assertThat(adapter.deliveryStates(List.of())).isEmpty(); + assertThat(adapter.deliveryStates(List.of("one", "two"))).containsExactlyInAnyOrderEntriesOf(Map.of("one", "PENDING", "two", "DEAD")); + assertThatThrownBy(() -> adapter.deliveryStates(Collections.nCopies(101, "one"))).hasMessageContaining("最多100"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java new file mode 100644 index 000000000..57c7da87b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.fleet.exception; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetHandoverContractTest { + @AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); } + + @Test void listPermissionCanInspectButCannotHandoverOrEndResources() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", + List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); + var controller = context.getBean(PlatformFleetHandoverController.class); + var service = context.getBean(FleetHandoverService.class); + controller.page("TR-1", new FleetHandoverModels.Query(1, 10)); + controller.preview("TR-1", null); + verify(service).preview("TR-1", null); + assertThrows(AccessDeniedException.class, () -> controller.confirm("TR-1", null, null)); + assertThrows(AccessDeniedException.class, () -> controller.release("TR-1", "HO-1", null, null)); + } + } + + @Test void everyPublicMethodHasAContractAndIndependentWriteAuthority() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-handovers-v1.yaml"))); + Map paths = (Map) document.get("paths"); + String base = PlatformFleetHandoverController.class.getAnnotation(RequestMapping.class).value()[0]; + int count = 0; + for (var method : PlatformFleetHandoverController.class.getDeclaredMethods()) { + String suffix, verb; + if (method.isAnnotationPresent(GetMapping.class)) { var pathsOnMethod = method.getAnnotation(GetMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "get"; } + else if (method.isAnnotationPresent(PostMapping.class)) { var pathsOnMethod = method.getAnnotation(PostMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "post"; } + else continue; + var path = (Map) paths.get(base + suffix); assertNotNull(path, method.getName()); + var operation = (Map) path.get(verb); assertNotNull(operation, method.getName()); + var authority = method.getAnnotation(PreAuthorize.class); assertNotNull(authority); + String permission = authority.value().split("'")[1]; assertTrue(operation.get("description").toString().contains(permission)); + count++; + } + assertEquals(4, count); + } + + @Configuration + @EnableMethodSecurity + static class Config { + @Bean FleetHandoverService service() { return mock(FleetHandoverService.class); } + @Bean PlatformFleetHandoverController controller(FleetHandoverService service) { return new PlatformFleetHandoverController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java new file mode 100644 index 000000000..bb95bc67f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FleetHandoverPdaQueryTest { + final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class); + final FleetTripMapper trips = mock(FleetTripMapper.class); + final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class); + final FleetDispatchGateway dispatches = mock(FleetDispatchGateway.class); + final FleetHandoverRepository handovers = mock(FleetHandoverRepository.class); + final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + final FleetHandoverPdaQuery service = new FleetHandoverPdaQuery(bindings, trips, history, dispatches, handovers, json); + + @BeforeEach void setup() { + var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A"); binding.setCustomerId("CUSTOMER-A"); + binding.setTripId(1L); binding.setTripNo("TR-ONE"); binding.setPackageCount(30); + var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setWarehouseId(92L); + trip.setStatus("IN_TRANSIT"); trip.setRecordVersion(10L); + when(bindings.findByDispatchNo("PC-A")).thenReturn(binding); when(trips.findByNo("TR-ONE")).thenReturn(trip); + var departed = new FleetTripHistory(); departed.setSnapshotJson(""" + {"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL","driverName":"原司机"}} + """); + when(history.selectList(any())).thenReturn(List.of(departed)); + when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of( + new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z")), + "DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)), + new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null), + "WAIT_DELIVERY", 3, new FleetTransitEvidence.Transport("REPLACEMENT", "接替司机", null))))); + } + + @Test void crossWarehouseFailsBeforeAnyCargoOrHistoryQuery() { + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(93, "PC-A")); + verifyNoInteractions(dispatches, history, handovers); + } + @Test void selectedDispatchRetainsOriginalAndPerDeliveryResourcesWithoutInternalIdentifiers() throws Exception { + var result = service.status(92, "PC-A"); + assertEquals("ORIGINAL", result.originalDeparture().vehiclePlate()); + assertEquals("ORIGINAL", result.deliveries().get(0).transport().vehiclePlate()); + assertEquals("REPLACEMENT", result.deliveries().get(1).transport().vehiclePlate()); + String serialized = json.writeValueAsString(result); + assertFalse(serialized.contains("customerId")); assertFalse(serialized.contains("warehouseId")); assertFalse(serialized.contains("vehicleId")); + verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("CUSTOMER-A") && value.contains("PC-A"))); + verify(dispatches, never()).handover(any(), any()); + } + @Test void staleBindingToAnotherCustomerCannotExposeDeliveryResources() { + when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-B", "92", 30, List.of())); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A")); + verifyNoInteractions(history, handovers); + } + @Test void controllerHasOnlyReadRouteAndRequiresThePdaIdentityPolicy() { + var policy = PdaFleetHandoverController.class.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value(); + assertTrue(policy.contains("lastleg:pda:operate")); assertTrue(policy.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + // Coverage instrumentation may add synthetic methods. Count all HTTP mappings (including + // composed mappings), so a newly exposed write route still fails this read-only contract. + var routes = java.util.Arrays.stream(PdaFleetHandoverController.class.getDeclaredMethods()) + .map(method -> org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation( + method, org.springframework.web.bind.annotation.RequestMapping.class)) + .filter(java.util.Objects::nonNull).toList(); + assertEquals(1, routes.size()); + assertArrayEquals(new org.springframework.web.bind.annotation.RequestMethod[] { org.springframework.web.bind.annotation.RequestMethod.GET }, routes.get(0).method()); + assertArrayEquals(new String[] { "/{dispatchNo}/handover-status" }, routes.get(0).path()); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> new PdaFleetHandoverController(service).status(null, "PC-A")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java new file mode 100644 index 000000000..27e0035ef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import static com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.*; +import static org.junit.jupiter.api.Assertions.*; + +class FleetHandoverPolicyTest { + private final Instant departure = Instant.parse("2026-10-03T01:00:00Z"); + private final Instant handover = departure.plusSeconds(7200); + private final Load capacity = load(2, 200, 4); + private final Cargo a = new Cargo("A", "PC-A", "1", "P1", "FBA:ONE", 10); + private final Cargo b = new Cargo("B", "PC-B", "2", "P1", "FBA:ONE", 20); + private final List measured = List.of(new Pallet("P1", load(1, 100, 2))); + + @Test void sharedPalletKeepsCustomerOwnershipAndCountsCapacityOnce() { + var result = inspectBoth(delivery(a, null), delivery(b, null)); + assertEquals(List.of(a, b), result.remainingCargo()); + assertEquals(load(1, 100, 2), result.occupied()); + assertEquals(1, result.remainingPalletIds().size()); + } + + @Test void signedCustomersCargoIsExcludedWithoutGuessingResidualWeight() { + var signed = delivery(a, departure.plusSeconds(60)); + var result = inspectBoth(signed, delivery(b, null)); + assertEquals(List.of(b), result.remainingCargo()); + assertEquals(List.of(signed), result.completedDeliveries()); + assertEquals(load(1, 100, 2), result.occupied()); + } + + @Test void unresolvedRefusalRemainsCargoUntilActualReturnFact() { + var result = inspectBoth(delivery(a, null), delivery(b, departure.plusSeconds(60))); + assertEquals(List.of(a), result.remainingCargo()); + } + + @Test void allTerminalCargoRequiresCompletionRatherThanEmptyHandover() { + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover), delivery(b, handover))); + } + + @Test void sameDestinationCannotMixCustomerOrDispatchFacts() { + var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null); + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong)); + } + + @Test void missingDuplicateAndChangedQuantityFactsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null))); + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), delivery(a, null), delivery(b, null))); + var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null); + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong)); + } + + @Test void cannotBackdateHandoverAcrossLaterSignatureOrBeforeDeparture() { + assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover.plusSeconds(1)), delivery(b, null))); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, departure.minusSeconds(1), handover, + List.of(a), List.of(delivery(a, null)), measured, capacity)); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover.plusSeconds(1), handover, + List.of(a), List.of(delivery(a, null)), measured, capacity)); + } + + @Test void rejectsAllThreeCapacityOverflows() { + for (Load tooSmall : List.of(load(1, 99, 2), load(1, 100, 1), load(0, 100, 2))) + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a), List.of(delivery(a, null)), measured, tooSmall)); + } + + @Test void missingOrDuplicateMeasurementsAndMixedDestinationPalletAreRejected() { + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a), List.of(delivery(a, null)), List.of(), capacity)); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a), List.of(delivery(a, null)), List.of(measured.get(0), measured.get(0)), capacity)); + var wrong = new Cargo("B", "PC-B", "2", "P1", "FBA:TWO", 20); + assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover, + List.of(a, wrong), List.of(delivery(a, null), delivery(wrong, null)), measured, capacity)); + } + + @Test void fullyUnloadedPalletDoesNotConsumeReplacementCapacity() { + var other = new Cargo("B", "PC-B", "2", "P2", "FBA:TWO", 20); + var result = inspect(departure, handover, handover, List.of(a, other), + List.of(delivery(a, handover), delivery(other, null)), + List.of(measured.get(0), new Pallet("P2", load(1, 50, 1))), load(1, 50, 1)); + assertEquals(load(1, 50, 1), result.occupied()); + assertEquals(List.of(other), result.remainingCargo()); + } + + private Inspection inspectBoth(Delivery... deliveries) { + return inspect(departure, handover, handover, List.of(a, b), List.of(deliveries), measured, capacity); + } + private static Delivery delivery(Cargo cargo, Instant terminalAt) { + return new Delivery(cargo.customerId(), cargo.dispatchNo(), "D-" + cargo.customerId(), + cargo.destinationKey(), cargo.packages(), terminalAt); + } + private static Load load(int pallets, int kg, int cbm) { + return new Load(pallets, BigDecimal.valueOf(kg), BigDecimal.valueOf(cbm)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java new file mode 100644 index 000000000..29049855f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java @@ -0,0 +1,171 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real transaction/repository/resource locks; credential, cargo and existing trip-history ports are isolated. */ +class FleetHandoverTransactionTest { + private JdbcTemplate jdbc; + private FleetHandoverService service; + private FleetHandoverRepository repository; + private FleetTripService history; + private FleetDispatchGateway dispatches; + private DriverService drivers; + private VehicleApplicationService vehicleService; + private Instant at, end; + private Selection selection; + + @BeforeEach void setup() throws Exception { + at = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS); end = at.plusSeconds(3600); + var source = new DriverManagerDataSource("jdbc:h2:mem:handover-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE trip_state(id BIGINT PRIMARY KEY,vehicle_id BIGINT,driver_id BIGINT,record_version BIGINT)"); + jdbc.update("INSERT INTO trip_state VALUES(1,11,21,7)"); + jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))"); + jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',11,1,'TR-ONE'),('DRIVER',21,1,'TR-ONE')"); + jdbc.execute(""" + CREATE TABLE ll_fleet_trip_handover( + id BIGINT PRIMARY KEY,trip_id BIGINT NOT NULL,trip_version BIGINT NOT NULL,trip_no VARCHAR(32) NOT NULL,handover_no VARCHAR(32) NOT NULL UNIQUE, + request_key VARCHAR(128) NOT NULL,request_hash CHAR(64) NOT NULL,record_version BIGINT NOT NULL, + handed_over_at TIMESTAMP(3) NOT NULL,snapshot_json LONGTEXT NOT NULL, + vehicle_release_json LONGTEXT,driver_release_json LONGTEXT,created_by VARCHAR(64) NOT NULL,created_at TIMESTAMP(3) NOT NULL, + UNIQUE(trip_id,request_key),UNIQUE(trip_id,trip_version)) + """); + var config = new MybatisConfiguration(); config.addMapper(FleetResourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config); + var resources = new SqlSessionTemplate(factory.getObject()).getMapper(FleetResourceMapper.class); + var trips = mock(FleetTripMapper.class); + when(trips.lockByNo("TR-ONE")).thenAnswer(call -> state()); when(trips.findByNo("TR-ONE")).thenAnswer(call -> state()); + var vehicles = mock(VehicleRepository.class); + when(vehicles.lockById(anyLong())).thenAnswer(call -> Optional.of(new VehicleRepository.VehicleSnapshot(call.getArgument(0), + "PLATE-" + call.getArgument(0), "TRUCK", BigDecimal.ONE, 10, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, + 0, "1", LocalDateTime.now(), 2, BigDecimal.valueOf(200), BigDecimal.valueOf(4)))); + drivers = mock(DriverService.class); + var qualification = new DriverQualificationPolicy.Qualification(true, true, DriverQualificationPolicy.Reason.ELIGIBLE, LocalDate.now().plusDays(15)); + when(drivers.requireDispatchable(anyLong(), any())).thenAnswer(call -> new DriverModels.Response(Long.toString(call.getArgument(0)), "DR-NEW", + new DriverModels.Draft("接替司机", "phone", "OWNED", "OVERSEAS", "license", "C", null, LocalDate.now().plusDays(15), "UTC", null, null), + true, "APPROVED", "file", 0, "1", LocalDateTime.now(), qualification, "VALID", null, null)); + var inspection = mock(FleetHandoverInspectionService.class); + var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10); + var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null); + var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10, + List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone")))); + when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(), + new FleetHandoverPolicy.Inspection(List.of(cargo), List.of(), Set.of("pallet"), new Load(1, BigDecimal.valueOf(100), BigDecimal.valueOf(2))), List.of(evidence))); + history = mock(FleetTripService.class); + doAnswer(call -> { + FleetTripEntity trip = call.getArgument(0); + jdbc.update("UPDATE trip_state SET vehicle_id=?,driver_id=?,record_version=record_version+1 WHERE id=1", trip.getVehicleId(), trip.getDriverId()); + return null; + }).when(history).recordExecution(any(), anyLong(), anyString(), anyString(), anyString()); + dispatches = mock(FleetDispatchGateway.class); vehicleService = mock(VehicleApplicationService.class); + repository = new FleetHandoverRepository(jdbc); + var target = new FleetHandoverService(trips, resources, history, dispatches, vehicles, vehicleService, drivers, inspection, repository, + new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (FleetHandoverService) proxy.getProxy(); + selection = new Selection(7L, 12L, 22L, end, at); + } + + @Test void handoverRetainsOldResourcesAndReplaysWithoutRepeatingDeliveryWrite() { + var command = command("handover-one"); + var result = service.confirm("TR-ONE", "1", command); + assertEquals(4, countResources()); assertEquals(12L, state().getVehicleId()); assertEquals(22L, state().getDriverId()); + assertEquals("11", result.snapshot().previous().vehicleId()); assertEquals("12", result.snapshot().replacement().vehicleId()); + assertEquals(result.handoverNo(), service.confirm("TR-ONE", "another-operator", command).handoverNo()); + verify(dispatches, times(1)).handover(any(), any()); + verify(dispatches, never()).depart(anyString(), anyString(), anyString(), anyString(), any(), any()); + assertEquals(1, repository.count(1)); + } + + @Test void failureAfterResourceAndHistoryWritesRollsBackEverything() { + var command = command("handover-fail"); + doAnswer(call -> { jdbc.update("UPDATE trip_state SET vehicle_id=12 WHERE id=1"); throw new IllegalStateException("history failed"); }) + .when(history).recordExecution(any(), anyLong(), eq("HANDOVER"), anyString(), anyString()); + assertThrows(IllegalStateException.class, () -> service.confirm("TR-ONE", "1", command)); + assertEquals(2, countResources()); assertEquals(0, repository.count(1)); assertEquals(11L, state().getVehicleId()); + } + + @Test void stalePreviewAndOccupiedReplacementDoNotWrite() { + var command = command("handover-stale"); + jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',12,2,'TR-TWO')"); + assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", command)); + assertEquals(0, repository.count(1)); verifyNoInteractions(dispatches); + jdbc.update("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=12"); + var stale = new Confirm(selection, "0".repeat(64), "交接地点", null, "handover-stale"); + assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", stale)); assertEquals(2, countResources()); + } + + @Test void sameKeyWithDifferentContentFailsBeforeAnySecondMutation() { + var command = command("handover-replay"); service.confirm("TR-ONE", "1", command); + var changed = new Confirm(selection, command.cargoHash(), "另一地点", null, command.idempotencyKey()); + assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", changed)); + verify(dispatches, times(1)).handover(any(), any()); + } + + @Test void oldResourceReleaseReplayCannotReleaseItsNextTrip() { + var row = service.confirm("TR-ONE", "1", command("handover-release")); + var release = new Release(0L, "DRIVER", at.plusSeconds(1), "任务结束地点", null, "release-driver"); + var released = service.release("TR-ONE", row.handoverNo(), "1", release); + assertNotNull(released.driverRelease()); assertEquals(3, countResources()); + jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',21,2,'TR-TWO')"); + service.release("TR-ONE", row.handoverNo(), "1", release); + assertEquals(2L, jdbc.queryForObject("SELECT trip_id FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=21", Long.class)); + } + + @Test void failedVehicleStatusChangeRestoresOriginalReservation() { + var row = service.confirm("TR-ONE", "1", command("handover-vehicle")); + when(vehicleService.changeStatus(anyLong(), anyString(), any())).thenThrow(new IllegalStateException("status failed")); + assertThrows(IllegalStateException.class, () -> service.release("TR-ONE", row.handoverNo(), "1", + new Release(0L, "VEHICLE", at.plusSeconds(1), "维修站", "MAINTENANCE", "release-vehicle"))); + assertEquals(4, countResources()); assertNull(repository.find(1, row.handoverNo()).vehicleRelease()); + } + + @Test void currentUnchangedVehicleCannotBeReleasedAsOldResource() { + selection = new Selection(7L, 11L, 22L, end, at); + var row = service.confirm("TR-ONE", "1", command("handover-driver-only")); + assertEquals(3, countResources()); + assertThrows(RuntimeException.class, () -> service.release("TR-ONE", row.handoverNo(), "1", + new Release(0L, "VEHICLE", at.plusSeconds(1), "原地", "IN_SERVICE", "release-unchanged"))); + } + + private Confirm command(String key) { + var preview = service.preview("TR-ONE", selection); + assertTrue(preview.driverQualification().expiringSoon()); + return new Confirm(selection, preview.cargoHash(), "交接地点", null, key); + } + private int countResources() { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class); } + private FleetTripEntity state() { + return jdbc.queryForObject("SELECT * FROM trip_state WHERE id=1", (r, ignored) -> { + var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setStatus("IN_TRANSIT"); trip.setWarehouseId(92L); + trip.setVehicleId(r.getLong("vehicle_id")); trip.setVehiclePlate("PLATE-" + r.getLong("vehicle_id")); + trip.setDriverId(r.getLong("driver_id")); trip.setDriverName("司机"); trip.setDriverPhone("phone"); + trip.setRecordVersion(r.getLong("record_version")); trip.setDepartedAt(LocalDateTime.ofInstant(at.minusSeconds(3600), ZoneOffset.UTC)); + return trip; + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java new file mode 100644 index 000000000..141128e05 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetTransitEvidenceTest { + @BeforeAll static void initializeDeliveryMetadata() { + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( + new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "fleet-transit-evidence"), + DeliveryOrder.class); + } + + final DispatchOrderMapper orders = mock(DispatchOrderMapper.class); + final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class); + final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class); + final DeliveryOperationMapper operations = mock(DeliveryOperationMapper.class); + final DispatchPdaWorkflowService workflow = mock(DispatchPdaWorkflowService.class); + final DispatchFleetAdapter adapter = new DispatchFleetAdapter(orders, mock(DispatchPdaTaskMapper.class), + outbounds, deliveries, mock(DispatchItemMapper.class), mock(DispatchPdaPickMapper.class), workflow, operations); + final DispatchOrder dispatch = new DispatchOrder(); + final DeliveryOrder delivery = new DeliveryOrder(); + + FleetTransitEvidenceTest() { + dispatch.setId(11L); dispatch.setDispatchNo("PC-1"); dispatch.setCustomerId("CUSTOMER-A"); + dispatch.setOverseasWarehouseId("21"); dispatch.setStatus("DEPARTED"); dispatch.setShippedPackageCount(10); + var outbound = new OutboundOrder(); outbound.setId(31L); outbound.setStatus("OUTBOUNDED"); outbound.setPackageCount(10); + delivery.setId(41L); delivery.setDeliveryNo("DL-1"); delivery.setCustomerId("CUSTOMER-A"); + delivery.setDispatchOrderId(11L); delivery.setOutboundOrderId(31L); delivery.setOverseasWarehouseId("21"); + delivery.setPackageCount(10); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); + delivery.setDestinationType("FBA"); delivery.setDestinationWarehouseCode("ONT8"); + when(orders.findGlobalByDispatchNo("PC-1")).thenReturn(dispatch); + when(orders.lockGlobalByDispatchNo("PC-1")).thenReturn(dispatch); + when(outbounds.findByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound); + when(outbounds.lockByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound); + when(deliveries.findByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery)); + when(deliveries.lockByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery)); + } + + @Test void unresolvedReturnKeepsCargoAndUsesCustomerScopedLocks() { + delivery.setStatus("RETURN_PENDING"); + var fact = adapter.transitEvidence("PC-1", true); + assertNull(fact.deliveries().get(0).cargo().terminalAt()); + assertEquals("FBA:ONT8", fact.deliveries().get(0).cargo().destinationKey()); + var order = inOrder(orders, outbounds, deliveries); + order.verify(orders).lockGlobalByDispatchNo("PC-1"); + order.verify(outbounds).lockByDispatch("CUSTOMER-A", 11L); + order.verify(deliveries).lockByOutbound("CUSTOMER-A", 31L); + verifyNoInteractions(workflow); + } + + @Test void priorSignatureRemainsTerminalAfterLaterReturnRequest() { + var signed = LocalDateTime.of(2026, 10, 3, 9, 0); + delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed); + var fact = adapter.transitEvidence("PC-1", false); + assertEquals(signed.atZone(ZoneId.systemDefault()).toInstant(), fact.deliveries().get(0).cargo().terminalAt()); + verifyNoInteractions(operations, workflow); + } + + @Test void unsignedReturnRequiresImmutableReturnOperation() { + delivery.setStatus("RETURNED"); delivery.setUpdatedAt(LocalDateTime.now()); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + var returned = LocalDateTime.of(2026, 10, 3, 10, 0); + when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned); + assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(), + adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt()); + } + + @Test void crossCustomerAndUnbalancedFactsFailClosed() { + delivery.setCustomerId("CUSTOMER-B"); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + delivery.setCustomerId("CUSTOMER-A"); delivery.setPackageCount(9); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + verifyNoInteractions(workflow); + } + + @Test void preDepartureAndCancelledDeliveryCannotBecomeHandoverCargo() { + dispatch.setStatus("READY_TO_DEPART"); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + dispatch.setStatus("DEPARTED"); delivery.setStatus("CANCELLED"); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false)); + } + + @Test void handoverUpdatesOnlyUnfinishedDeliveryAndNeverCallsDeparture() { + var evidence = adapter.transitEvidence("PC-1", true); + when(deliveries.lockByNo("CUSTOMER-A", "DL-1")).thenReturn(delivery); + when(deliveries.update(isNull(), any())).thenReturn(1); + adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone")); + verify(deliveries).update(isNull(), any()); + verify(orders, never()).update(any(), any()); + verifyNoInteractions(workflow); + } + + @Test void signedCargoStaysOnOriginalAssignmentAndStaleVersionCannotOverwrite() { + delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0)); + var evidence = adapter.transitEvidence("PC-1", true); + var assignment = new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone"); + adapter.handover(evidence, assignment); + verify(deliveries, never()).update(any(), any()); + delivery.setVersion(1L); + assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.handover(evidence, assignment)); + verifyNoInteractions(workflow); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java new file mode 100644 index 000000000..87e618f33 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java @@ -0,0 +1,125 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class VehicleCareFinancePersistenceTest { + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private JdbcTemplate jdbc; + private VehicleCareFinanceService service; + private VehicleCareMapper records; + private VehicleCareFinanceClient finance; + private VehicleCareSupplierClient suppliers; + private VehicleCareEntity row; + @BeforeEach void setup() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:care-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_fleet_finance_source(id BIGINT PRIMARY KEY,record_id BIGINT NOT NULL,source_version BIGINT NOT NULL,record_version BIGINT NOT NULL,operator_id VARCHAR(64) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,event_id VARCHAR(128) NOT NULL,payload_json CLOB NOT NULL,created_at TIMESTAMP(3),UNIQUE(record_id,source_version),UNIQUE(record_id,operator_id,request_key),UNIQUE(event_id))"); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(128),event_type VARCHAR(64),event_version INT,event_key VARCHAR(255) UNIQUE,payload_json CLOB,status VARCHAR(32),retry_count INT,next_retry_at TIMESTAMP(3),published_at TIMESTAMP(3),locked_at TIMESTAMP(3),locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP(3))"); + var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var outbox = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); + records = mock(VehicleCareMapper.class); finance = mock(VehicleCareFinanceClient.class); suppliers = mock(VehicleCareSupplierClient.class); + row = new VehicleCareEntity(); row.setId(1L); row.setVehicleId(71L); row.setVehiclePlate("VEH-A"); row.setRecordNo("VR-ONE"); row.setRecordType("REPAIR"); row.setRecordVersion(0L); row.setDetailsJson(json.writeValueAsString(draft("10", "PENDING"))); + when(records.lock(71, "VR-ONE")).thenReturn(row); + when(records.findFinanceRecords(71, List.of("VR-ONE"))).thenReturn(List.of(row)); + when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of())); + when(suppliers.get("test-token", 8, true)).thenReturn(ApiResponse.success(new VehicleCareSupplierClient.Supplier(8, "维修供应商", null, "ACTIVE"))); + var converter = mock(OverseasFeeCurrencyConverter.class); + when(converter.convert(eq("USD"), any(), eq(LocalDate.of(2026, 1, 1)))).thenAnswer(call -> { + BigDecimal amount = call.getArgument(1); + return new OverseasFeeCurrencyConverter.Conversion("USD", amount.setScale(2), new BigDecimal("7.000000"), "2026-01", amount.multiply(new BigDecimal("7")).setScale(2)); + }); + var adapterProxy = new ProxyFactory(new VehicleCareFinanceAdapter(converter, outbox, jdbc, json)); adapterProxy.setProxyTargetClass(true); + adapterProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + var accounting = (VehicleCareFinanceGateway) adapterProxy.getProxy(); + var target = new VehicleCareFinanceService(records, jdbc, json, accounting, finance, suppliers, "test-token"); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (VehicleCareFinanceService) proxy.getProxy(); + } + private static VehicleCareModels.Draft draft(String amount, String processing) { + return new VehicleCareModels.Draft("REPAIR", Instant.parse("2026-01-01T00:00:00Z"), null, null, null, null, null, null, null, null, + new VehicleCareModels.Incident("轮胎损坏", "换轮胎", "维修站", "工作人员", processing, null, new BigDecimal(amount), "USD")); + } + private static VehicleCareFinanceModels.Submit command(String key) { return new VehicleCareFinanceModels.Submit(0L, 8L, LocalDate.of(2026, 1, 1), key); } + private VehicleCareFinanceModels.Source source() throws Exception { + return json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_fleet_finance_source ORDER BY source_version DESC LIMIT 1", String.class), VehicleCareFinanceModels.Source.class); + } + private void remote(String status) throws Exception { + var source = source(); + when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of(new VehicleCareFinanceModels.Cost("VC-ONE", "PLATFORM_DEFAULT", source, "维修供应商", "CNY", status, + "PAID".equals(status) ? source.amount() : BigDecimal.ZERO, "PAID".equals(status) ? BigDecimal.ZERO : source.amount(), 1)))); + } + @Test void submitWritesImmutableSourceAndOutboxTogetherAndUnknownResponseReplaysTheSameEvent() throws Exception { + var first = service.submit(71, "VR-ONE", "1", command("request-one")); + var replay = service.submit(71, "VR-ONE", "1", command("request-one")); + assertThat(first.status()).isEqualTo("PENDING_FINANCE"); assertThat(replay.replayed()).isTrue(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isEqualTo(1); + var envelope = json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class), EventEnvelope.class); + assertThat(envelope.eventType()).isEqualTo(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); + assertThat(envelope.tenantId()).isEqualTo("0"); assertThat(envelope.payload().has("customerId")).isFalse(); + assertThat(source().amount()).isEqualByComparingTo("70.00"); + verify(finance, times(1)).query(anyString(), any()); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(0L, 9L, LocalDate.of(2026,1,1), "request-one"))).hasMessageContaining("其他内容"); + } + @Test void outboxFailureRollsBackTheSourceAndAllowsTheOriginalRequestToRetry() { + jdbc.execute("ALTER TABLE ll_event_outbox ADD CONSTRAINT reject_pending CHECK(status<>'PENDING')"); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero(); + jdbc.execute("ALTER TABLE ll_event_outbox DROP CONSTRAINT reject_pending"); + assertThat(service.submit(71, "VR-ONE", "1", command("request-one")).sourceVersion()).isEqualTo(1); + } + @Test void statusesDistinguishUnlinkedDeliveryFailureAndRealFinancePayment() throws Exception { + assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("UNLINKED"); + verifyNoInteractions(finance); + service.submit(71, "VR-ONE", "1", command("request-one")); + assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("PENDING_FINANCE"); + jdbc.update("UPDATE ll_event_outbox SET status='DEAD'"); + assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("FINANCE_ATTENTION"); + remote("PAID"); + var status = service.statuses(71, List.of("VR-ONE")).get(0); + assertThat(status.status()).isEqualTo("PAID"); assertThat(status.finance().paidAmount()).isEqualByComparingTo("70"); + } + @Test void submittedAmountsStayFrozenUntilFinancialVoidButProcessingUpdatesContinue() throws Exception { + service.submit(71, "VR-ONE", "1", command("request-one")); remote("PENDING"); + assertThatThrownBy(() -> service.guardFinancialEdit(row, draft("20", "PENDING"))).hasMessageContaining("先在财务作废"); + clearInvocations(finance); + service.guardFinancialEdit(row, draft("10", "COMPLETED")); verifyNoInteractions(finance); + remote("PAID"); assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-two"))).hasMessageContaining("不可改写"); + remote("VOIDED"); service.guardFinancialEdit(row, draft("20", "PENDING")); + row.setDetailsJson(json.writeValueAsString(draft("20", "PENDING"))); + assertThat(service.submit(71, "VR-ONE", "1", command("request-two")).sourceVersion()).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(2); + } + @Test void wrongVehicleStaleVersionAndFinanceFailureCannotPretendToSubmit() { + assertThatThrownBy(() -> service.submit(72, "VR-ONE", "1", command("request-one"))).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.statuses(72, List.of("VR-ONE"))).hasMessageContaining("不存在"); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(1L,8L,LocalDate.of(2026,1,1),"request-one"))).hasMessageContaining("已变化"); + when(finance.query(anyString(), any())).thenReturn(null); + assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).hasMessageContaining("未确认入账或付款"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java index 026801b22..6efa92b94 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java @@ -50,7 +50,7 @@ class VehicleCarePersistenceTest { files = mock(VehicleCareFileGateway.class); when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8)); var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files, - new ObjectMapper().registerModule(new JavaTimeModule())); + new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class)); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); service = (VehicleCareService) proxy.getProxy(); @@ -128,7 +128,7 @@ class VehicleCarePersistenceTest { if (List.of("REPAIR", "RESCUE").contains(type)) { assertThat(row.details().incident().item()).isEqualTo("更换轮胎"); assertThat(row.details().incident().place()).isEqualTo("实际作业地点"); - assertThat(row.paymentStatus()).isEqualTo("UNLINKED"); + assertThat(row.paymentStatus()).isNull(); } } } @@ -137,7 +137,7 @@ class VehicleCarePersistenceTest { var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD"))); var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD"))); assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0"); - assertThat(completed.paymentStatus()).isEqualTo("UNLINKED"); + assertThat(completed.paymentStatus()).isNull(); assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE"); var history = service.history(vehicleA, created.recordNo(), 1, 20).records(); assertThat(history).hasSize(3); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java new file mode 100644 index 000000000..3cd34b78c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetQuoteDirectoryTest { + @Test void saleUsesCustomerAndPurchaseRequiresAnActiveOverseasTruckingSupplier() { + var customers=mock(FleetCustomerDirectory.class);var suppliers=mock(FleetQuoteSupplierClient.class); + var directory=new FleetQuoteDirectory(customers,suppliers,"test-only-token"); + when(customers.required(1)).thenReturn(new FleetCustomerDirectory.Customer("1","C001","委托客户",null,null)); + assertThat(directory.name("SALE",1)).isEqualTo("委托客户");verifyNoInteractions(suppliers); + when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"国内拖车","TRUCKING","ACTIVE",List.of(),"DOMESTIC"))); + assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("海外车队"); + when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"海外车队","TRUCKING","ACTIVE",List.of(),"OVERSEAS"))); + assertThat(directory.name("PURCHASE",2)).isEqualTo("海外车队"); + } + @Test void unavailableDirectoryDoesNotCreateFakeNames() { + var suppliers=mock(FleetQuoteSupplierClient.class);var directory=new FleetQuoteDirectory(mock(FleetCustomerDirectory.class),suppliers,"test-only-token"); + when(suppliers.get(anyString(),anyLong(),anyBoolean(),anyString())).thenThrow(new IllegalStateException("unavailable")); + assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("目录暂不可用"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java new file mode 100644 index 000000000..591993cbb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.LocalDate; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetQuotePersistenceTest { + private JdbcTemplate jdbc; private FleetQuoteService service; + @BeforeEach void setup() throws Exception { + var source=new DriverManagerDataSource("jdbc:h2:mem:quotes-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(source); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")) { + var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema); + assertThat(match.find()).as("总控集成表结构 %s",table).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT")); + } + var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("正式往来单位"); + var target=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource())); + service=(FleetQuoteService)proxy.getProxy(); + } + private FleetQuoteModels.Draft draft(String kind,String city,BigDecimal price) { + return new FleetQuoteModels.Draft(kind,1L,city,"ont8",new BigDecimal("10.0"),"USD",price,null,null,LocalDate.of(2026,1,1),LocalDate.of(2026,12,31),null); + } + @Test void nullPriceDiffersFromZeroAndSalesNeverAliasPurchase() { + assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("invalid-key",draft("SALE","LA",null)))).hasMessageContaining("至少填写"); + var sale=service.create("1",new FleetQuoteModels.Create("sale-key",draft("SALE","LA",BigDecimal.ZERO))); + var purchase=service.create("1",new FleetQuoteModels.Create("purchase-key",draft("PURCHASE","LA",BigDecimal.TEN))); + assertThat(sale.details().selfPickupPrice()).isEqualByComparingTo("0");assertThat(sale.details().fullDeliveryPrice()).isNull(); + assertThat(purchase.quoteNo()).isNotEqualTo(sale.quoteNo());assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isEqualTo(2); + } + @Test void replayAndCorrectionKeepVersionsAndRejectStaleWrites() { + var request=new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.TEN));var first=service.create("1",request); + assertThat(service.create("1",request).quoteNo()).isEqualTo(first.quoteNo()); + assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.ONE)))).hasMessageContaining("不同报价"); + service.update(first.quoteNo(),"1",new FleetQuoteModels.Update(0L,draft("SALE","LA",BigDecimal.ONE))); + assertThat(service.create("1",request).details().selfPickupPrice()).isEqualByComparingTo("10"); + assertThat(service.history(first.quoteNo())).hasSize(2);assertThat(service.history(first.quoteNo()).get(1).snapshot().details().selfPickupPrice()).isEqualByComparingTo("10"); + assertThatThrownBy(()->service.status(first.quoteNo(),"1",new FleetQuoteModels.Status(0L,"INACTIVE"))).hasMessageContaining("已变化"); + } + @Test void failedAtomicImportLeavesNoRowsAndChangedBatchCannotReuseKey() { + var row=draft("SALE","LA",BigDecimal.ONE); + assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,row)))).hasMessageContaining("重复"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote_history",Integer.class)).isZero(); + service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row))); + assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,draft("SALE","SF",BigDecimal.ONE))))).hasMessageContaining("不同报价"); + } + @Test void deletePreservesHistoryAndExportEscapesSpreadsheetFormulas() { + var created=service.create("1",new FleetQuoteModels.Create("formula-key",draft("SALE","=2+2",BigDecimal.ZERO))); + assertThat(new String(service.exportCsv(new FleetQuoteModels.Query(1,20,null,null,null,null)),java.nio.charset.StandardCharsets.UTF_8)).contains("'=2+2"); + service.delete(created.quoteNo(),"1",0);assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).total()).isZero(); + assertThat(service.history(created.quoteNo())).hasSize(2); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java new file mode 100644 index 000000000..d56fa7759 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.util.UUID; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TripOperationsRecordPersistenceTest { + private JdbcTemplate jdbc;private TripOperationsRecordRepository repository;private TripOperationsRecordService service; + @BeforeEach void setup() throws Exception { + var source=new DriverManagerDataSource("jdbc:h2:mem:trip-measured-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");jdbc=new JdbcTemplate(source); + jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),departed_at DATETIME)"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES('TR-A','IN_TRANSIT','2026-10-01 00:00:00'),('TR-B','PLANNED',NULL)"); + var match=Pattern.compile("(?s)CREATE TABLE ll_fleet_trip_operations_record \\(.*?;").matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + assertThat(match.find()).as("总控集成实测记录表").isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT")); + repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule())); + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));service=(TripOperationsRecordService)proxy.getProxy(); + } + private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Details(distance,fuel,null),null);} + @Test void onlyDepartedTripsCanBeMeasuredAndMissingValuesAreNotZero() { + assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();assertThat(service.get("TR-A").recordVersion()).isZero(); + assertThatThrownBy(()->service.save("TR-B","1",request(0,"before-depart",BigDecimal.ONE,null))).hasMessageContaining("实际已发车"); + var saved=service.save("TR-A","1",request(0,"save-zero",BigDecimal.ZERO,null)); + assertThat(saved.details().actualDistanceMiles()).isEqualByComparingTo("0");assertThat(saved.details().actualFuelLitres()).isNull(); + assertThat(jdbc.queryForObject("SELECT status FROM ll_fleet_trip WHERE trip_no='TR-A'",String.class)).isEqualTo("IN_TRANSIT"); + } + @Test void replayReturnsOriginalSnapshotAndStaleUpdatesCannotOverwriteNewFacts() { + var first=request(0,"first-key",new BigDecimal("10.0"),BigDecimal.ONE);service.save("TR-A","1",first); + service.save("TR-A","1",request(1,"second-key",new BigDecimal("20"),BigDecimal.TEN)); + assertThat(service.save("TR-A","1",first).details().actualDistanceMiles()).isEqualByComparingTo("10"); + assertThatThrownBy(()->service.save("TR-A","1",request(1,"stale-key",BigDecimal.ONE,null))).hasMessageContaining("已变化"); + assertThatThrownBy(()->service.save("TR-A","1",request(0,"first-key",BigDecimal.ONE,null))).hasMessageContaining("其他实测内容"); + assertThat(service.history("TR-A",1,1).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isEqualByComparingTo("20"); + } + @Test void unchangedFactsDoNotInventExtraHistoryAndExplicitClearIsAudited() { + service.save("TR-A","1",request(0,"create-key",new BigDecimal("10.0"),null)); + assertThat(service.save("TR-A","1",request(1,"no-change",new BigDecimal("10.00"),null)).recordVersion()).isEqualTo(1); + service.save("TR-A","1",request(1,"clear-key",null,null)); + assertThat(service.history("TR-A",1,20).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull(); + } + @Test void failedAppendRollsBackAllMeasuredFacts() { + doAnswer(invocation->{invocation.callRealMethod();throw new IllegalStateException("audit-failure");}).when(repository).append(anyLong(),anyString(),anyString(),anyString(),any()); + assertThatThrownBy(()->service.save("TR-A","1",request(0,"failure-key",BigDecimal.ONE,null))).hasMessageContaining("audit-failure"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record",Integer.class)).isZero(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java new file mode 100644 index 000000000..986e0f673 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.baiyue.lastleg.fleet.trip.FleetTripCostService; +import com.baiyue.lastleg.fleet.trip.FleetTripCostModels; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TripOperationsServiceTest { + static final String DEPARTURE=""" + {"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"}, + "confirmedLoad":{"cargo":[ + {"dispatchNo":"D-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","packages":10,"destinationKey":"FBA:ONT8","destinationLabel":"ONT8"}, + {"dispatchNo":"D-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P1","packages":5,"destinationKey":"FBA:LAX9","destinationLabel":"LAX9"}], + "pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}]}, + "externalBookings":[{"bookingNo":"FB1","status":"BOUND","customerId":"1","dispatchNo":"D-A","agreedAmount":0,"cargo":{"currency":"USD"}, + "receipt":{"source":{"receivedPackages":4,"pallets":[{"warehousePalletId":"P1","packages":4}]}}}]} + """; + @Test void mixedCustomersDestinationsAndCargoCountOneDepartureAndOnePhysicalPallet() { + var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null); + when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","COMPLETED",Instant.parse("2026-10-02T00:00:00Z"),DEPARTURE,null))); + var result=service.report(query,false);assertThat(result.summary().trips()).isEqualTo(1);assertThat(result.summary().physicalPallets()).isEqualTo(1);assertThat(result.summary().palletSpaces()).isEqualTo(2); + assertThat(result.summary().externalPackages()).isEqualTo(4);assertThat(result.summary().ownPackages()).isEqualTo(11); + var row=result.page().records().get(0);assertThat(row.vehiclePlate()).isEqualTo("OLD-PLATE");assertThat(row.financeStatus()).isEqualTo("NOT_QUERIED");assertThat(row.costStatus()).isEqualTo("FORBIDDEN"); + assertThat(row.externalAgreedAmounts().get(0).amount()).isEqualByComparingTo("0");verify(repo,times(1)).departures(query); + } + @Test void missingSnapshotAndOverstatedExternalQuantityFailInsteadOfInventingZero() { + var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,null,null))).hasMessageContaining("不可变历史"); + assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE.replace("\"receivedPackages\":4","\"receivedPackages\":20").replace("\"packages\":4","\"packages\":20"),null))).hasMessageContaining("超过实际发车"); + } + @Test void summaryUsesAllFilteredDeparturesNotCurrentPageAndSeparatesCurrencies() { + var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,"EXTERNAL",null); + when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("USD","CNY").replace("TR-A","TR-B"),null))); + var result=service.report(query,false);assertThat(result.page().records()).hasSize(1);assertThat(result.summary().trips()).isEqualTo(2);assertThat(result.summary().externalAgreedAmounts()).hasSize(2); + assertThat(result.charts().physicalPallets().get(0).points().get(0).value()).isEqualByComparingTo("2"); + assertThat(result.charts().destinationTrips().get(0).points()).hasSize(2); + assertThat(result.charts().externalPalletRates()).isEmpty(); + } + @Test void perPalletChartUsesWeightedConfirmedQuantityAndExcludesOrderPricing() { + var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class)); + String a=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":20").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"PALLET\",\"palletSpaces\":2"); + String b=a.replace("\"agreedAmount\":20","\"agreedAmount\":10").replace("\"palletSpaces\":2","\"palletSpaces\":8"); + String order=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":100").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"ORDER\""); + var charts=TripOperationsCharts.from(List.of(service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,a,null)), + service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,b,null)),service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,order,null)))); + assertThat(charts.externalPalletRates().get(0).points().get(0).value()).isEqualByComparingTo("3.00"); + assertThat(charts.externalAgreedAmounts().get(0).points().get(0).value()).isEqualByComparingTo("130"); + } + @Test void costsComeFromOneBatchAndUnknownAllocationsNeverBecomeZero() { + var repo=mock(TripOperationsRepository.class);var costs=mock(FleetTripCostService.class); + var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),costs); + var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null); + when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("TR-A","TR-B"),null))); + var registered=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of(new FleetTripCostModels.Total("USD",new java.math.BigDecimal("10.00"),List.of(),null,null))); + var empty=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of()); + when(costs.summaries(List.of("TR-A","TR-B"))).thenReturn(java.util.Map.of("TR-A",registered,"TR-B",empty)); + var report=service.report(query,true);verify(costs,times(1)).summaries(List.of("TR-A","TR-B")); + assertThat(report.summary().costs().unrecordedTrips()).isEqualTo(1);assertThat(report.summary().costs().unallocatedTrips()).isEqualTo(1); + assertThat(report.summary().costs().totals().get(0).directCost()).isEqualByComparingTo("10.00");assertThat(report.summary().costs().totals().get(0).ownCost()).isNull(); + assertThat(report.page().records().get(1).costStatus()).isEqualTo("NOT_RECORDED"); + clearInvocations(costs);assertThat(service.report(query,false).summary().costs().visible()).isFalse();verifyNoInteractions(costs); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java new file mode 100644 index 000000000..b4b40606a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.yaml.snakeyaml.Yaml; +import java.nio.file.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetTripCostContractTest { + private static final String READ="platform:fleet:trip:list", CREATE="platform:fleet:trip:cost-create", EDIT="platform:fleet:trip:cost-edit"; + private void login(String... permissions) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused", + Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); + } + @Test void readingDoesNotGrantCostWritesAndPlanEditingDoesNotGrantCostEditing() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformFleetTripCostController.class); + var service = context.getBean(FleetTripCostService.class); + var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L); + var query = new FleetTripCostModels.Query(1, 10); + var create = new FleetTripCostModels.Create("test-key", null); + var update = new FleetTripCostModels.Update(0L, null, null); + login(READ, "platform:fleet:trip:edit"); + controller.list("TR-ONE", query); controller.history("TR-ONE", "TC-ONE", query); + assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class); + login(CREATE); controller.create("TR-ONE", user, create); + assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class); + login(EDIT); controller.update("TR-ONE", "TC-ONE", user, update); + assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class); + login(); assertThatThrownBy(() -> controller.list("TR-ONE", query)).isInstanceOf(AccessDeniedException.class); + verify(service).list("TR-ONE", query); verify(service).history("TR-ONE", "TC-ONE", query); + verify(service).create("TR-ONE", "8", create); verify(service).update("TR-ONE", "TC-ONE", "8", update); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + @Test void everyPublicOperationAndPayloadFieldHasAnExplicitContract() throws Exception { + Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trip-costs-v1.yaml"))); + Map paths = (Map) document.get("paths"); + String base = PlatformFleetTripCostController.class.getAnnotation(RequestMapping.class).value()[0]; + for (var method : PlatformFleetTripCostController.class.getDeclaredMethods()) { + String verb, suffix; + if (method.isAnnotationPresent(GetMapping.class)) { verb="get"; var values=method.getAnnotation(GetMapping.class).value(); suffix=values.length==0 ? "" : values[0]; } + else if (method.isAnnotationPresent(PostMapping.class)) { verb="post"; suffix=""; } + else if (method.isAnnotationPresent(PutMapping.class)) { verb="put"; suffix=method.getAnnotation(PutMapping.class).value()[0]; } + else continue; + Map operation = (Map) ((Map) paths.get(base+suffix)).get(verb); + String expression = method.getAnnotation(PreAuthorize.class).value(); + for (String permission : List.of(READ, CREATE, EDIT)) if (expression.contains(permission)) + assertThat(operation.get("description").toString()).contains(permission); + } + Map schemas = (Map) ((Map) document.get("components")).get("schemas"); + for (var type : List.of(FleetTripCostModels.Cost.class, FleetTripCostModels.Draft.class, FleetTripCostModels.Allocation.class, + FleetTripCostModels.Summary.class, FleetTripCostModels.Total.class, FleetTripCostModels.Listing.class, + FleetTripCostModels.Snapshot.class, FleetTripCostModels.History.class)) { + Map properties = (Map) ((Map) schemas.get(type.getSimpleName())).get("properties"); + assertThat(properties.keySet().stream().map(Object::toString).toList()).containsExactlyInAnyOrder( + Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toArray(String[]::new)); + } + assertThat(FleetTripCostService.class.getMethod("list", String.class, FleetTripCostModels.Query.class).getAnnotation(Transactional.class).readOnly()).isTrue(); + } + @Configuration @EnableMethodSecurity static class Config { + @Bean FleetTripCostService service() { return mock(FleetTripCostService.class); } + @Bean PlatformFleetTripCostController controller(FleetTripCostService service) { return new PlatformFleetTripCostController(service); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java new file mode 100644 index 000000000..9240e8647 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java @@ -0,0 +1,189 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.*; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class FleetTripCostPersistenceTest { + private JdbcTemplate jdbc; + private FleetTripCostRepository repository; + private FleetTripCostService service; + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private static final FleetTripCostModels.Query PAGE = new FleetTripCostModels.Query(1, 10); + static final String DEPARTURE = """ + {"confirmedLoad":{"manifestHash":"frozen-departure", "cargo":[ + {"dispatchNo":"DS-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","palletNo":"PAL-1","packages":3,"destinationKey":"FBA:A"}, + {"dispatchNo":"DS-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P2","palletNo":"PAL-2","packages":2,"destinationKey":"FBA:A"}], + "pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}, + {"warehousePalletId":"P2","palletSpaces":1,"weightKg":50,"volumeCbm":1,"deliveryIndex":1}]}, + "externalBookings":[{"bookingNo":"FB-B","customerId":"2","status":"BOUND","dispatchNo":"DS-B", + "receipt":{"source":{"receivedPackages":2,"pallets":[{"warehousePalletId":"P2","packages":2}]}}}]} + """; + @BeforeEach void setup() throws Exception { + var source = new DriverManagerDataSource("jdbc:h2:mem:trip-cost-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", ""); + jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json LONGTEXT)"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-A','COMPLETED'),(2,'TR-B','PLANNED')"); + jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,7,'DEPART',?)", DEPARTURE); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String name : List.of("ll_fleet_trip_cost", "ll_fleet_trip_cost_history")) { + var match = Pattern.compile("(?s)CREATE TABLE " + name + " \\(.*?;").matcher(schema); + assertThat(match.find()).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT")); + } + var config = new MybatisConfiguration(); config.addMapper(FleetTripMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class); + repository = spy(new FleetTripCostRepository(jdbc)); + var proxy = new ProxyFactory(new FleetTripCostService(mapper, repository, json)); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (FleetTripCostService) proxy.getProxy(); + } + private static FleetTripCostModels.Draft draft(String currency, String amount) { + return new FleetTripCostModels.Draft("FUEL", currency, new BigDecimal(amount), Instant.parse("2026-01-01T00:00:00Z"), null); + } + private FleetTripCostModels.Cost create(String trip, String key, String currency, String amount) { + return service.create(trip, "1", new FleetTripCostModels.Create(key, draft(currency, amount))); + } + @Test void departureOnlyWeightsConserveCentsAndNeverMixCurrencies() { + create("TR-A", "CNY-ONE", "CNY", "100.00"); create("TR-A", "USD-ONE", "USD", "0.01"); + var listing = service.list("TR-A", PAGE); + assertThat(listing.summary().departureVersion()).isEqualTo(7); + assertThat(listing.summary().totals()).hasSize(2); + var cny = listing.summary().totals().get(0); + assertThat(cny.ownCost()).isEqualByComparingTo("66.67"); assertThat(cny.externalCost()).isEqualByComparingTo("33.33"); + for (var total : listing.summary().totals()) assertThat(total.allocations().stream().map(FleetTripCostModels.Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(total.directCost()); + assertThat(cny.allocations()).extracting(FleetTripCostModels.Allocation::businessNo).containsExactly("DS-A", "FB-B"); + assertThat(jdbc.queryForList("SELECT status FROM ll_fleet_trip WHERE id=1", String.class)).containsExactly("COMPLETED"); + assertThat(jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_trip_history WHERE trip_id=1", String.class)).isEqualTo(DEPARTURE); + } + @Test void batchSummaryReusesTheSameAmountsWithoutPerTripQueriesAndDistinguishesMissingTrips() { + create("TR-A", "BATCH-COST", "CNY", "100.00"); + var expected = service.list("TR-A", PAGE).summary(); + clearInvocations(repository); + var result = service.summaries(List.of("TR-A", "TR-B", "MISSING", "TR-A")); + assertThat(result.keySet()).containsExactly("TR-A", "TR-B"); + assertThat(result.get("TR-A")).isEqualTo(expected); + assertThat(result.get("TR-B").totals()).isEmpty(); + verify(repository, never()).departure(anyLong()); + verify(repository, never()).totals(anyLong()); + verify(repository, times(1)).trips(anyCollection()); + verify(repository, times(1)).departures(anyCollection()); + verify(repository, times(1)).totals(anyCollection()); + assertThat(service.summaries(List.of())).isEmpty(); + assertThatThrownBy(() -> service.summaries(Collections.nCopies(101, "TR-A"))).hasMessageContaining("最多100"); + assertThatThrownBy(() -> service.summaries(List.of(" "))).hasMessageContaining("有效车次"); + } + @Test void createReplayAndConcurrentDuplicateCannotCreateTwoCosts() throws Exception { + var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5")); + var b = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5")); + assertThat(a.get(10, TimeUnit.SECONDS).costNo()).isEqualTo(b.get(10, TimeUnit.SECONDS).costNo()); + } finally { executor.shutdownNow(); } + assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1); + assertThatThrownBy(() -> create("TR-A", "SAME-KEY", "CNY", "6")).hasMessageContaining("其他成本内容"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history", Integer.class)).isEqualTo(1); + } + @Test void correctionRetainsOriginalAggregateAndRejectsOtherTripsAndStaleVersions() { + var original = create("TR-A", "COST-ONE", "USD", "30"); + var request = new FleetTripCostModels.Update(0L, draft("USD", "60"), "核对实际费用"); + assertThatThrownBy(() -> service.update("TR-B", original.costNo(), "1", request)).hasMessageContaining("不存在"); + service.update("TR-A", original.costNo(), "1", request); + assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", request)).hasMessageContaining("已变化"); + var history = service.history("TR-A", original.costNo(), PAGE).records(); + assertThat(history).hasSize(2); assertThat(history.get(0).reason()).isEqualTo("核对实际费用"); + assertThat(history.get(0).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("60"); + assertThat(history.get(1).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("30"); + } + @Test void equivalentMoneyAndEmptyRemarksReplayWithoutAnotherCostOrHistory() { + var original = create("TR-A", "EQUIVALENT-KEY", "USD", "30"); + var equivalent = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("30.00"), original.details().incurredAt(), " "); + var replay = service.create("TR-A", "1", new FleetTripCostModels.Create("EQUIVALENT-KEY", equivalent)); + assertThat(replay.costNo()).isEqualTo(original.costNo()); + var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, equivalent, "仅说明不同")); + assertThat(unchanged.recordVersion()).isZero(); + assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(1); + assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1); + service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null)); + assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null))) + .hasMessageContaining("已变化"); + } + @Test void millisecondStorageAndReplayingCreationAfterCorrectionPreserveTheCurrentCost() { + var input = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("12.50"), Instant.parse("2026-01-01T00:00:00.123456Z"), " 凭据说明 "); + var command = new FleetTripCostModels.Create("PRECISE-TIME-KEY", input); + var original = service.create("TR-A", "1", command); + assertThat(original.details().incurredAt()).isEqualTo(Instant.parse("2026-01-01T00:00:00.123Z")); + assertThat(original.details().remark()).isEqualTo("凭据说明"); + var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, input, null)); + assertThat(unchanged.recordVersion()).isZero(); + service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "20"), null)); + var replay = service.create("TR-A", "1", command); + assertThat(replay.recordVersion()).isEqualTo(1); + assertThat(replay.details().currency()).isEqualTo("CNY"); + assertThat(replay.details().amount()).isEqualByComparingTo("20"); + assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(2); + } + @Test void auditFailureRollsBackCostAndAmountCorrection() { + var original = create("TR-A", "ORIGINAL", "CNY", "30"); + doThrow(new IllegalStateException("history unavailable")).when(repository).audit(anyLong(), any(), anyString(), anyString(), anyString(), anyString()); + assertThatThrownBy(() -> create("TR-A", "REJECTED", "CNY", "90")).hasMessageContaining("history unavailable"); + assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "70"), null))).hasMessageContaining("history unavailable"); + var list = service.list("TR-A", PAGE); + assertThat(list.page().total()).isEqualTo(1); assertThat(list.page().records().get(0).recordVersion()).isZero(); + assertThat(list.summary().totals().get(0).directCost()).isEqualByComparingTo("30"); + } + @Test void noDepartureAndNoCostsAreUnknownAndDifferentStopsPreventInventedAllocations() { + assertThat(service.list("TR-B", PAGE).summary().totals()).isEmpty(); + create("TR-B", "BEFORE-DEPART", "USD", "0"); + var before = service.list("TR-B", PAGE).summary(); + assertThat(before.allocationBlockReason()).contains("尚无实际发车"); assertThat(before.totals().get(0).ownCost()).isNull(); + jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=1", DEPARTURE.replace("\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":1", "\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":2")); + create("TR-A", "MULTI-STOP", "USD", "50"); + var summary = service.list("TR-A", PAGE).summary(); + assertThat(summary.allocationBlockReason()).contains("卸货站不同"); assertThat(summary.totals().get(0).allocations()).isEmpty(); + assertThat(summary.totals().get(0).directCost()).isEqualByComparingTo("50"); + } + @Test void mismatchedExternalOwnerOrReceiptCannotBeClassifiedAsOwnCargo() throws Exception { + var wrongCustomer = json.readValue(DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":\"3\",\"status\""), FleetTripModels.AuditSnapshot.class); + assertThat(FleetTripCostBasis.from(wrongCustomer).blockReason()).contains("归属未核平"); + var wrongQuantity = json.readValue(DEPARTURE.replace("\"receivedPackages\":2", "\"receivedPackages\":3"), FleetTripModels.AuditSnapshot.class); + assertThat(FleetTripCostBasis.from(wrongQuantity).blockReason()).contains("总量"); + } + + @Test void activeUnboundExternalCargoCannotSilentlyBecomeOwnCargo() throws Exception { + for (var status : List.of("CONFIRMED", "RECEIVED", "UNKNOWN")) { + var snapshot = json.readValue(DEPARTURE.replace("\"status\":\"BOUND\"", "\"status\":\"" + status + "\""), FleetTripModels.AuditSnapshot.class); + assertThat(FleetTripCostBasis.from(snapshot).blockReason()).contains("实收及排车依据"); + } + } + + @Test void boundExternalCargoRequiresIdentityAndUniqueReceiptPallets() throws Exception { + for (var malformed : List.of( + DEPARTURE.replace("\"bookingNo\":\"FB-B\"", "\"bookingNo\":null"), + DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":null,\"status\""), + DEPARTURE.replace("\"status\":\"BOUND\",\"dispatchNo\":\"DS-B\"", "\"status\":\"BOUND\",\"dispatchNo\":null"))) { + assertThat(FleetTripCostBasis.from(json.readValue(malformed, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("实收及排车依据"); + } + var duplicate = DEPARTURE.replace("\"warehousePalletId\":\"P2\",\"packages\":2}", + "\"warehousePalletId\":\"P2\",\"packages\":2},{\"warehousePalletId\":\"P2\",\"packages\":2}"); + assertThat(FleetTripCostBasis.from(json.readValue(duplicate, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("归属未核平"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index dfc5a42c9..9f2b847da 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -476,6 +476,9 @@ class FleetTripPersistenceTest { assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED"); assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1); assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT"); + assertThat(result.departedAt().getNano() % 1_000_000).isZero(); + assertThat(service.detail(number).trip().departedAt()).isEqualTo(result.departedAt()); + assertThat(service.history(number, 1, 20).records().get(0).snapshot().trip().departedAt()).isEqualTo(result.departedAt()); assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue(); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java index 08773e1f1..baad0af40 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java @@ -13,7 +13,7 @@ import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -/** Exercises the production SQL with H2 adapters for MySQL's two JSON scalar functions. */ +/** Exercises the production SQL with H2 adapters for MySQL JSON scalar functions. */ public class VehicleFulfillmentQueryTest { private JdbcTemplate jdbc; private VehicleFulfillmentMapper mapper; @@ -22,19 +22,37 @@ public class VehicleFulfillmentQueryTest { private static final ObjectMapper JSON = new ObjectMapper(); public static String jsonExtract(String source, String path) throws Exception { if (source == null) return null; + if ("$".equals(path)) return source; var value = JSON.readTree(source); for (String field : path.substring(2).split("\\.")) value = value.path(field); return value.isMissingNode() ? null : value.toString(); } public static String jsonUnquote(String value) throws Exception { return value == null ? null : JSON.readTree(value).asText(); } + public static String jsonQuote(String value) throws Exception { return JSON.writeValueAsString(value); } + public static int jsonContains(String source, String candidate, String path) throws Exception { + String array = jsonExtract(source, path); + if (array == null) return 0; + var expected = JSON.readTree(candidate); + for (var entry : JSON.readTree(array)) if (entry.equals(expected)) return 1; + return 0; + } + public static String jsonSet(String source, String path, String value) throws Exception { + var document = (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(source); + document.set(path.substring(2), JSON.readTree(value)); + return document.toString(); + } @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); jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonExtract'"); jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonUnquote'"); + jdbc.execute("CREATE ALIAS JSON_QUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonQuote'"); + jdbc.execute("CREATE ALIAS JSON_CONTAINS FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonContains'"); + jdbc.execute("CREATE ALIAS JSON_SET FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonSet'"); jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_handover(id BIGINT PRIMARY KEY,trip_id BIGINT,trip_version BIGINT,snapshot_json CLOB,UNIQUE(trip_id,trip_version))"); jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)"); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(VehicleFulfillmentMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration); @@ -104,4 +122,24 @@ public class VehicleFulfillmentQueryTest { assertThatThrownBy(()->service.deliveries(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在"); assertThatThrownBy(()->service.changes(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在"); } + @Test void handoverAssociatesOnlyActuallyCarriedDeliveriesAndScopesChangeHistory() throws Exception { + history(0,"DEPART","8","18"); history(1,"HANDOVER","9","19"); + jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='IN_TRANSIT'"); + String snapshot = JSON.writeValueAsString(Map.of("replacement", Map.of("vehicleId", "9"), + "carriedDeliveries", List.of(Map.of("customerId", "CUSTOMER-B", "deliveryNo", "DL-22")), + "cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-12"), Map.of("dispatchNo", "DP-12"))))); + jdbc.update("INSERT INTO ll_fleet_trip_handover VALUES(1,1,1,?)", snapshot); + assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)).records()).hasSize(2); + assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).records()) + .extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-22"); + assertThat(service.changes(9,new VehicleFulfillmentModels.Query(1,10)).records()).singleElement().satisfies(change -> { + assertThat(change.relationship()).isEqualTo("REPLACED_IN"); + assertThat(change.dispatchNos()).containsExactly("DP-12"); + }); + jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED' WHERE id=22"); + assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isEqualTo(1); + // Matching delivery numbers alone must never associate another customer's document. + jdbc.update("UPDATE ll_delivery_order SET customer_id='CUSTOMER-A' WHERE id=22"); + assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isZero(); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index ae25afa78..f87f6e648 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -16,7 +16,9 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class OutboxLeaseDatabaseTest { - @Test void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"OverseasContainerUnloaded", "CompanyVehicleCostSubmitted"}) + void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff(String eventType) throws Exception { var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var jdbc = new JdbcTemplate(dataSource); 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(160),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)"); @@ -30,9 +32,9 @@ class OutboxLeaseDatabaseTest { ReflectionTestUtils.setField(repository, "mapper", mapper); var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); - jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3)); - assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); - assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", eventType, now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); assertThat(repository.claim(1L, "new", now)).isFalse(); var next = now.plusSeconds(10); @@ -40,7 +42,7 @@ class OutboxLeaseDatabaseTest { () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1); assertThat(repository.claim(1L, "early", now)).isFalse(); - assertThat(journal.begin("new", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", next, + assertThat(journal.begin("new", "lastleg-service", eventType, "1", null, "consumer", next, () -> repository.claim(1L, "new", next))).isTrue(); assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, () -> repository.dead(1L, "old", 12, "late"))).isFalse(); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index a2dde1a0e..295791f22 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -55,6 +55,11 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", + "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway", + "reason": "费用适配器仅实现Fleet公司成本换算与Outbox端口,Fleet不读取费用模块或Outbox持久化实现;同事务发布且不新增package循环。" + }, { "source": "com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter", "target": "com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway", 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 ab7582ab3..c3a120b00 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 145]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 152]]) { 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, '') @@ -28,7 +28,45 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|145) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|152) AS initialized_tables;$/) + } +}) + +test('车次直接成本按车次幂等并保留独立币种与不可变更正历史', () => { + const cost = table(overseas, 'baiyue_lastleg', 'll_fleet_trip_cost') + const history = table(overseas, 'baiyue_lastleg', 'll_fleet_trip_cost_history') + assert.match(cost, /UNIQUE KEY uk_fleet_trip_cost_request\(trip_id,created_by,request_key\)/i) + assert.match(cost, /FOREIGN KEY\(trip_id\) REFERENCES ll_fleet_trip\(id\)/i) + assert.match(cost, /CHECK\(amount>=0\)/i) + assert.match(cost, /CHECK\(currency IN \('CNY','USD'\)\)/i) + assert.match(history, /UNIQUE KEY uk_fleet_trip_cost_history\(cost_id,record_version\)/i) + assert.match(history, /FOREIGN KEY\(cost_id\) REFERENCES ll_fleet_trip_cost\(id\)/i) + assert.match(history, /snapshot_json JSON NOT NULL/i) +}) + +test('车辆报价与实测记录保留业务去重和不可变版本', () => { + const quote = table(overseas, 'baiyue_lastleg', 'll_fleet_quote') + assert.match(quote, /UNIQUE KEY uk_fleet_quote_no \(quote_no\)/i) + assert.match(quote, /UNIQUE KEY uk_fleet_quote_natural \(natural_key\)/i) + assert.match(quote, /UNIQUE KEY uk_fleet_quote_create \(created_by,create_key\)/i) + assert.match(table(overseas, 'baiyue_lastleg', 'll_fleet_quote_history'), /UNIQUE KEY uk_fleet_quote_history_version \(quote_id,record_version\)/i) + const measured = table(overseas, 'baiyue_lastleg', 'll_fleet_trip_operations_record') + assert.match(measured, /UNIQUE KEY uk_fleet_operations_version \(trip_no,record_version\)/i) + assert.match(measured, /UNIQUE KEY uk_fleet_operations_request \(trip_no,operator_id,request_key\)/i) +}) + +test('公司车辆费用隔离于客户账务并约束实际付款金额', () => { + const cost = table(cn, 'baiyue_finance', 'fin_vehicle_cost') + assert.doesNotMatch(cost, /\b(customer_id|tenant_id)\b/i) + assert.match(cost, /subject_id BIGINT UNSIGNED NOT NULL/i) + assert.match(cost, /UNIQUE KEY uk_fin_vehicle_cost_source \(subject_id,source_type,source_no\)/i) + assert.match(cost, /CHECK \(amount>0 AND paid_amount>=0 AND paid_amount<=amount\)/i) + assert.match(table(cn, 'baiyue_finance', 'fin_vehicle_cost_payment'), /FOREIGN KEY \(cost_id\) REFERENCES fin_vehicle_cost\(id\)/i) + assert.match(table(cn, 'baiyue_finance', 'fin_vehicle_cost_revision'), /UNIQUE KEY uk_fin_vehicle_cost_request \(operator_id,action,request_key\)/i) + const admin = roleAuthorities(cn, 'PLATFORM_ADMIN'), merchant = roleAuthorities(cn, 'MERCHANT_OWNER') + for (const action of ['list', 'approve', 'void', 'pay', 'upload']) { + assert.ok(admin.has(`platform:finance:vehicle-cost:${action}`)) + assert.ok(!merchant.has(`platform:finance:vehicle-cost:${action}`)) } }) @@ -56,7 +94,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 392, iam_role_permission: 573, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 411, iam_role_permission: 592, 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) @@ -84,9 +122,17 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) } - for (const action of ['list', 'create', 'edit', 'assign', 'cancel']) { + for (const action of ['list', 'create', 'edit', 'assign', 'cancel', 'cost-create', 'cost-edit']) { assert.ok(admin.has(`platform:fleet:trip:${action}`)) } + for (const action of ['list', 'create', 'edit', 'status', 'delete', 'import', 'export']) { + assert.ok(admin.has(`platform:fleet:quote:${action}`)) + assert.ok(!merchant.has(`platform:fleet:quote:${action}`)) + } + for (const action of ['list', 'export', 'edit']) { + assert.ok(admin.has(`platform:fleet:operations:${action}`)) + assert.ok(!merchant.has(`platform:fleet:operations:${action}`)) + } assert.ok(!permissions.some(row => row.permission_code === 'platform:order:complete'), '订单完成必须由可信履约回写产生,不能恢复人工完成权限') for (const code of ['platform:order:declaration:edit', 'platform:firstleg:inbound:hold',