feat(fleet): integrate costs quotes operations and in-transit handovers

This commit is contained in:
2026-10-03 12:38:04 +08:00
parent 274ecac474
commit cb6c54ffe5
107 changed files with 7304 additions and 42 deletions
@@ -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);
};
}
@@ -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";
@@ -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");
@@ -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;
+4 -3
View File
@@ -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 不算成功。
## 平台管理员密码
@@ -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;
+133 -1
View File
@@ -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;
+1
View File
@@ -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校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
@@ -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, "车辆运维照片必须归属平台");
}
@@ -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);
@@ -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: {}
@@ -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("平台");
+1
View File
@@ -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` |
@@ -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<DeliveryAck> 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<List<VehicleCostModels.Cost>> 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", "内部服务令牌无效");
}
}
@@ -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<PageResult<VehicleCostModels.Cost>> list(@Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.list(query)); }
@GetMapping("/{costNo}")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')")
public ApiResponse<VehicleCostModels.Cost> detail(@PathVariable String costNo) { return ApiResponse.success(costs.detail(costNo)); }
@GetMapping("/{costNo}/history")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')")
public ApiResponse<PageResult<VehicleCostModels.History>> 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<PageResult<VehicleCostModels.Paid>> 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<VehicleCostModels.Cost> 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<VehicleCostModels.Cost> 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<VehicleCostModels.Cost> 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<VehicleCostFileClient.Metadata> 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<byte[]> 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();
}
}
@@ -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 { }
@@ -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 { }
@@ -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<Metadata> 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> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long costId, @PathVariable long fileId);
@GetMapping("/{costId}/{fileId}/content")
ResponseEntity<byte[]> 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) { }
}
@@ -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<String> voucherFileIds, String operatorName, Instant recordedAt, String remark) { }
}
@@ -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<Row> 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<VehicleCostModels.Cost> 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<VehicleCostModels.Cost> 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<Object>(); 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<VehicleCostModels.History> 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<VehicleCostModels.Paid> 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> T read(String value, Class<T> 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); }
}
@@ -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<String> 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<byte[]> 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> T call(java.util.function.Supplier<T> 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", "公司车辆付款凭证服务不可用"); }
}
@@ -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<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC");
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
+59
View File
@@ -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和数据隔离键',
@@ -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<DeliveryAck>;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<List<Cost>>'}
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}
@@ -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);
}
}
}
@@ -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())));
}
}
@@ -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<Boolean> first = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-a", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } };
Callable<Boolean> 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();
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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));
@@ -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
+5 -1
View File
@@ -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 完成。
@@ -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);
@@ -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<DeliveryAck> checkedVehicleCost(ApiResponse<DeliveryAck> 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;
}
}
@@ -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<DeliveryAck> vehicleCost(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
@PostMapping("/internal/finance/v1/wallet-command-events")
ApiResponse<DeliveryAck> wallet(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
@@ -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();
@@ -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<com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery>();
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<DeliveryOrder>()
.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());
@@ -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<FleetHandoverPdaQuery.Status> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) {
var identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.status(identity.overseasWarehouseId(), dispatchNo));
}
}
@@ -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<String, String> deliveryStates(List<String> keys) {
if (keys.size() > 100) throw new IllegalArgumentException("车辆成本投递查询最多100个事件");
if (keys.isEmpty()) return Map.of();
var result = new HashMap<String, String>();
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);
}
}
@@ -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<FleetTransitEvidence> 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<FleetTripHistory>()
.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<String, FleetTripModels.DispatchBinding> 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<String> 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<FleetTransitEvidence> 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);
}
}
@@ -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<FleetTransitEvidence> executionEvidence, List<DeliveryReference> 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; }
}
}
@@ -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<Delivery> 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<FleetTripHistory>().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, "当前仓库没有该排车交接记录"); }
}
@@ -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<Cargo> remainingCargo, List<Delivery> completedDeliveries,
Set<String> 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<Cargo> departureCargo, List<Delivery> deliveries,
List<Pallet> 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<Destination, Integer> shipped = new HashMap<>();
Map<String, String> palletDestinations = new HashMap<>();
Set<Item> 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<Destination, Delivery> byDestination = new HashMap<>();
Set<DeliveryKey> deliveryKeys = new HashSet<>();
List<Delivery> 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<String, Load> 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<Cargo> remaining = departureCargo.stream()
.filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList();
if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理");
Set<String> 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); }
}
@@ -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> 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<Row> 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());
}
}
@@ -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<Response> 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<FleetTransitEvidence> 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> T read(String value, Class<T> 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); }
}
@@ -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<Delivery> deliveries) {
public record Transport(String vehiclePlate, String driverName, String driverPhone) { }
public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { }
}
@@ -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<PageResult<FleetHandoverModels.Response>> 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<FleetHandoverModels.Preview> 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<FleetHandoverModels.Response> 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<FleetHandoverModels.Response> 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();
}
}
@@ -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<List<VehicleCareFinanceModels.Status>> 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<VehicleCareFinanceModels.Submitted> 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));
}
}
@@ -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<List<VehicleCareFinanceModels.Cost>> query(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody VehicleCareFinanceModels.SourceQuery request);
}
@@ -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<String, String> deliveryStates(List<String> eventKeys);
}
@@ -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<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 Status(String recordNo, String status, Long sourceVersion, Long submittedRecordVersion, Cost finance) { }
public record Submitted(String recordNo, long sourceVersion, long submittedRecordVersion, boolean replayed, String status) { }
}
@@ -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<VehicleCareFinanceModels.Status> statuses(long vehicleId, List<String> 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.<String, VehicleCareFinanceModels.Cost>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<String, VehicleCareFinanceModels.Cost> queryFinance(List<VehicleCareEntity> 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<String, VehicleCareFinanceModels.Cost>();
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<Long, Submission> latest(List<Long> 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<Long, Submission>(); 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> T read(String value, Class<T> 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, "当前车辆维修救援记录不存在"); }
}
@@ -5,6 +5,10 @@ import org.apache.ibatis.annotations.*;
@Mapper
public interface VehicleCareMapper extends BaseMapper<VehicleCareEntity> {
@Select({"<script>SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no IN",
"<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>",
"AND record_type IN ('REPAIR','RESCUE')</script>"})
java.util.List<VehicleCareEntity> findFinanceRecords(@Param("vehicleId") long vehicleId, @Param("numbers") java.util.List<String> 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")
@@ -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<VehicleCareModels.Response> 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);
@@ -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<Supplier> get(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long supplierId,
@RequestParam boolean requireActive);
record Supplier(long supplierId, String name, String category, String status) { }
}
@@ -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,"供应商目录暂不可用,请重试"); }
}
}
@@ -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<Quote> records) { }
}
@@ -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<Row> 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<Row> list(FleetQuoteModels.Query q) {
StringBuilder sql=new StringBuilder("SELECT * FROM ll_fleet_quote WHERE status<>'DELETED'"); var args=new ArrayList<Object>();
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<Audit> 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);}
}
@@ -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<FleetQuoteModels.Quote> 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<FleetQuoteModels.Quote> 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<FleetQuoteModels.History> 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<FleetQuoteModels.Quote>();
String batchHash=hash(write(request.rows().stream().map(FleetQuoteService::normalize).toList()));
for(int i=0;i<request.rows().size();i++)rows.add(createOne(operator,new FleetQuoteModels.Create(request.idempotencyKey()+":"+(i+1),request.rows().get(i)),batchHash));
return new FleetQuoteModels.ImportResult(rows.size(),List.copyOf(rows));
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public byte[] exportCsv(FleetQuoteModels.Query q) {
var csv=new StringBuilder("\uFEFF报价号,用途,往来单位,发货城市,目的仓码,预估距离(mile),币种,整车自提(每单),整车派送(每单),散板派送(每托),生效日,失效日,状态,版本,备注\r\n");
for(var r:list(q)){var d=r.details();csv.append(String.join(",",cell(r.quoteNo()),cell(d.kind()),cell(r.partyName()),cell(d.originCity()),cell(d.destinationCode()),cell(d.distanceMiles()),cell(d.currency()),cell(d.selfPickupPrice()),cell(d.fullDeliveryPrice()),cell(d.looseDeliveryPrice()),cell(d.validFrom()),cell(d.validTo()),cell(r.status()),cell(r.recordVersion()),cell(d.remark()))).append("\r\n");}
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
static FleetQuoteModels.Draft normalize(FleetQuoteModels.Draft d) {
if(d.validTo().isBefore(d.validFrom()))throw bad("失效日不得早于生效日");
if(d.selfPickupPrice()==null&&d.fullDeliveryPrice()==null&&d.looseDeliveryPrice()==null)throw bad("至少填写一种报价,留空表示未报价");
return new FleetQuoteModels.Draft(d.kind(),d.partyId(),d.originCity().trim(),d.destinationCode().trim().toUpperCase(Locale.ROOT),d.distanceMiles().stripTrailingZeros(),d.currency(),
decimal(d.selfPickupPrice()),decimal(d.fullDeliveryPrice()),decimal(d.looseDeliveryPrice()),d.validFrom(),d.validTo(),d.remark()==null?null:d.remark().trim());
}
private static java.math.BigDecimal decimal(java.math.BigDecimal n){return n==null?null:n.stripTrailingZeros();}
private String natural(FleetQuoteModels.Draft d){return hash(write(List.of(d.kind(),d.partyId(),d.originCity().toLowerCase(Locale.ROOT),d.destinationCode(),d.distanceMiles().toPlainString(),d.currency(),d.validFrom().toString(),d.validTo().toString())));}
private void version(FleetQuoteRepository.Row row,long expected){if("DELETED".equals(row.status())||row.version()!=expected)throw conflict("报价已变化,请重新加载后操作");}
private FleetQuoteRepository.Row required(String no,boolean lock){var row=repository.find(no,lock);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"报价不存在");return row;}
private void audit(FleetQuoteRepository.Row r,String action,String operator){repository.audit(IdWorker.getId(),r,action,operator,write(response(r)));}
private FleetQuoteModels.Quote response(FleetQuoteRepository.Row r){return new FleetQuoteModels.Quote(r.quoteNo(),read(r.details(),FleetQuoteModels.Draft.class),r.partyName(),r.status(),r.version(),r.updatedAt().toInstant(ZoneOffset.UTC));}
private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}}
private <T>T read(String value,Class<T> 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);}
}
@@ -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<Supplier> 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<String> carrierCodes,String truckingScope) { }
}
@@ -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<PageResult<FleetQuoteModels.Quote>> page(@Valid FleetQuoteModels.Query query){return ApiResponse.success(service.page(query));}
@GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
public ApiResponse<List<FleetCustomerDirectory.Customer>> 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<FleetQuoteModels.Quote> detail(@PathVariable String quoteNo){return ApiResponse.success(service.detail(quoteNo));}
@GetMapping("/{quoteNo}/history") @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
public ApiResponse<List<FleetQuoteModels.History>> history(@PathVariable String quoteNo){return ApiResponse.success(service.history(quoteNo));}
@PostMapping @PreAuthorize("hasAuthority('platform:fleet:quote:create')")
public ApiResponse<FleetQuoteModels.Quote> 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<FleetQuoteModels.Quote> 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<FleetQuoteModels.Quote> 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<FleetQuoteModels.Quote> 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<FleetQuoteModels.ImportResult> 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<byte[]> 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();}
}
@@ -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<TripOperationsModels.Result> 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<byte[]> 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");}
}
@@ -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<TripOperationsRecordModels.Record> get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));}
@GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<PageResult<TripOperationsRecordModels.History>> 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<TripOperationsRecordModels.Record> 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));
}
}
@@ -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<TripOperationsModels.Row> rows) {
var pallets=new TreeMap<String,BigDecimal>();var destinations=new TreeMap<String,BigDecimal>();var labels=new HashMap<String,String>();
var amounts=new TreeMap<String,Map<String,BigDecimal>>();var rateAmounts=new TreeMap<String,Map<String,BigDecimal>>();var quantities=new TreeMap<String,Map<String,BigDecimal>>();
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<String,Map<String,BigDecimal>>();
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<TripOperationsModels.Point> points(Map<String,BigDecimal> values){return values.entrySet().stream().map(e->new TripOperationsModels.Point(e.getKey(),e.getValue())).toList();}
private static List<TripOperationsModels.Series> series(Map<String,Map<String,BigDecimal>> values,boolean average){return values.entrySet().stream().map(e->new TripOperationsModels.Series(e.getKey(),e.getKey()+(average?"/标准托位":""),points(e.getValue()))).toList();}
}
@@ -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<Point> points) { }
public record Charts(List<Series> physicalPallets,List<Series> destinationTrips,List<Series> externalAgreedAmounts,List<Series> externalPalletRates) { }
public record Row(String tripNo, String status, String vehiclePlate, String driverName,
String warehouseName, Instant departedAt, Instant completedAt, List<Destination> destinations,
long physicalPallets, long palletSpaces, long ownPackages, long externalPackages,
List<Money> externalAgreedAmounts, List<PalletRate> 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<CostTotal> totals) { }
public record Summary(long trips, long inTransit, long completed, long physicalPallets, long palletSpaces,
long ownPackages, long externalPackages, List<Money> externalAgreedAmounts,CostSummary costs) { }
public record Result(PageResult<Row> page, Summary summary, Charts charts) { }
}
@@ -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) { }
}
@@ -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<Snapshot> 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<Snapshot> 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);
}
}
@@ -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<TripOperationsRecordModels.History> 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);}
}
@@ -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<Departure> 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<Object>();
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());
}
}
@@ -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<TripOperationsModels.Row> 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<TripOperationsModels.Row>();
for(int start=0;start<rows.size();start+=100) {
var chunk=rows.subList(start,Math.min(start+100,rows.size()));
var summaries=costs.summaries(chunk.stream().map(TripOperationsModels.Row::tripNo).toList());
for(var row:chunk) {
var summary=summaries.get(row.tripNo());
if(summary==null)throw new IllegalStateException("成本批量响应缺少车次"+row.tripNo());
result.add(row.withCosts(summary,summary.totals().isEmpty()?"NOT_RECORDED":"AVAILABLE"));
}
}
return List.copyOf(result);
}
TripOperationsModels.Row project(TripOperationsRepository.Departure fact) {
try {
if (fact.snapshot()==null) throw new IllegalStateException("实际发车缺少不可变历史,请核对车次"+fact.tripNo());
var snapshot=json.readValue(fact.snapshot(),FleetTripModels.AuditSnapshot.class);
var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
var rates=new ArrayList<TripOperationsModels.PalletRate>();
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<String>(); 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<String,String>(); var seen=new HashSet<String>();
var shippedByPallet=new HashMap<String,Long>();
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<TripOperationsModels.Row> rows,boolean costVisible) {
var money=new TreeMap<String,BigDecimal>();
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<TripOperationsModels.Row> rows,boolean visible) {
if(!visible)return new TripOperationsModels.CostSummary(false,null,null,List.of());
var totals=new TreeMap<String,TripOperationsModels.CostTotal>();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<TripOperationsModels.Money> amounts(Map<String,BigDecimal> 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); }
}
@@ -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);
}
@@ -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<Share> 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<String, FleetExecutionModels.Measurement>();
for (var pallet : load.pallets()) {
if (pallet.palletSpaces() < 1 || measured.put(pallet.warehousePalletId(), pallet) != null)
return blocked("发车实测托位缺失或托盘重复");
}
var cargoByPallet = new HashMap<String, List<FleetExecutionModels.Cargo>>();
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<String, FleetBookingModels.Response>();
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<String, Share>();
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); }
}
@@ -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<Allocation> allocations, BigDecimal ownCost, BigDecimal externalCost) { }
public record Summary(Long departureVersion, String manifestHash, String allocationMethod, String allocationBlockReason, List<Total> totals) { }
public record Listing(PageResult<Cost> page, Summary summary) { }
public record Snapshot(Cost cost, Summary summary) { }
public record History(long recordVersion, String action, String reason, Instant occurredAt, Snapshot snapshot) { }
}
@@ -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> 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<Row> 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<Amount> 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<String, Long> trips(Collection<String> numbers) {
var result = new LinkedHashMap<String, Long>();
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<Long, Departure> departures(Collection<Long> ids) {
var result = new LinkedHashMap<Long, Departure>();
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<Long, List<Amount>> totals(Collection<Long> ids) {
var result = new LinkedHashMap<Long, List<Amount>>();
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<Audit> 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); }
}
@@ -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<String, FleetTripCostModels.Summary> summaries(Collection<String> 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<String, FleetTripCostModels.Summary>();
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<FleetTripCostModels.History> 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<FleetTripCostRepository.Amount> amountsByCurrency) {
var basis = FleetTripCostBasis.from(departure == null ? null : read(departure.snapshot(), FleetTripModels.AuditSnapshot.class));
var totals = new ArrayList<FleetTripCostModels.Total>();
for (var total : amountsByCurrency) {
var allocations = new ArrayList<FleetTripCostModels.Allocation>();
BigDecimal own = null, external = null;
if (basis.blockReason() == null) {
var amounts = new HashMap<String, BigDecimal>();
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> T read(String value, Class<T> 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); }
}
@@ -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<FleetExecutionModels.DepartedDispatch>();
@@ -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<FleetTripCostModels.Listing> 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<PageResult<FleetTripCostModels.History>> 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<FleetTripCostModels.Cost> 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<FleetTripCostModels.Cost> 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));
}
}
@@ -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<VehicleFulfillmentModels.ChangeRow> changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size);
}
@@ -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<String>();
for (JsonNode binding : snapshot.path("dispatches")) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText());
var dispatches = new java.util.LinkedHashSet<String>();
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(),
@@ -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<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
@@ -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'));
@@ -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' }
@@ -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<String>)按最多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='车次里程油耗不可变更正记录';"
}
}
@@ -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)"
]
}
}
}
}
@@ -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' }
@@ -4,9 +4,9 @@ info:
version: 1.0.0
description: >-
平台按稳定车辆ID读取Lastleg本库履约事实,不按当前车牌文本反查,不修改单据或历史。
计划中的派送归当前分配车辆;实际发车后按不可变DEPART历史中的车辆身份归属。
计划中的派送归当前分配车辆;实际发车后按不可变DEPART及在途交接快照中的车辆身份归属。接替车辆仅关联交接时未完成且属于原客户的派送。
关联排车和派送时同时匹配客户归属,不返回财务、附件、联系人或内部数据库主键。
更换记录依据相邻车次版本的ASSIGN快照,首次分配和资源未变化的操作不算更换。
更换记录依据相邻车次版本的ASSIGN或HANDOVER快照;在途交接仅列实际剩余货物涉及的排车单,首次分配和资源未变化的操作不算更换。
旧车和新车均能读取同一更换事实;仅换司机也保留。返回历史车牌/姓名,不用当前主档覆盖。
不生成原型示例记录;没有正式车次关联的旧派送不通过车牌猜测归属。
security: [{bearerAuth: []}]
@@ -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}
@@ -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 {
@@ -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");
}
}
@@ -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<String, Object> 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); }
}
}
@@ -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"));
}
}
@@ -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<Pallet> 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));
}
}
@@ -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;
});
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
@@ -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("目录暂不可用");
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More