feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("平台");
|
||||
|
||||
@@ -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", "公司车辆付款凭证服务不可用"); }
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+61
@@ -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());
|
||||
|
||||
+22
@@ -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));
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+94
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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; }
|
||||
}
|
||||
}
|
||||
+71
@@ -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, "当前仓库没有该排车交接记录"); }
|
||||
}
|
||||
+104
@@ -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); }
|
||||
}
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
+208
@@ -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); }
|
||||
}
|
||||
+10
@@ -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) { }
|
||||
}
|
||||
+44
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+22
@@ -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) { }
|
||||
}
|
||||
+149
@@ -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, "当前车辆维修救援记录不存在"); }
|
||||
}
|
||||
+4
@@ -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")
|
||||
|
||||
+5
-3
@@ -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);
|
||||
|
||||
+13
@@ -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) { }
|
||||
}
|
||||
+39
@@ -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);}
|
||||
}
|
||||
+14
@@ -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) { }
|
||||
}
|
||||
+41
@@ -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();}
|
||||
}
|
||||
+25
@@ -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");}
|
||||
}
|
||||
+28
@@ -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));
|
||||
}
|
||||
}
|
||||
+32
@@ -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();}
|
||||
}
|
||||
+38
@@ -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) { }
|
||||
}
|
||||
+19
@@ -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) { }
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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);}
|
||||
}
|
||||
+32
@@ -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());
|
||||
}
|
||||
}
|
||||
+136
@@ -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) { }
|
||||
}
|
||||
+93
@@ -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); }
|
||||
}
|
||||
+143
@@ -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); }
|
||||
}
|
||||
+2
-1
@@ -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>();
|
||||
|
||||
+37
@@ -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));
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+48
@@ -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");
|
||||
}
|
||||
}
|
||||
+61
@@ -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); }
|
||||
}
|
||||
}
|
||||
+74
@@ -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"));
|
||||
}
|
||||
}
|
||||
+98
@@ -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));
|
||||
}
|
||||
}
|
||||
+171
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
+125
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+26
@@ -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("目录暂不可用");
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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();
|
||||
}
|
||||
}
|
||||
+70
@@ -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
Reference in New Issue
Block a user