Compare commits

..
Author SHA1 Message Date
ouyangxq 6f31fa2131 fix(appointments): open canonical dispatch details from calendar 2026-10-20 16:36:22 +08:00
147 changed files with 519 additions and 7861 deletions
@@ -1,10 +0,0 @@
import subprocess, shutil, xml.etree.ElementTree as ET
from pathlib import Path
root=Path.cwd()
report=root/'lastleg-service/target/surefire-reports/TEST-com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.xml'
properties=ET.parse(report).getroot().find('properties')
classpath=next(item.attrib['value'] for item in properties if item.attrib['name']=='java.class.path')
sources=[str(root/'lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip'/name) for name in ['FleetTripCalendarServiceTest.java','FleetTripCalendarMapperTest.java']]
subprocess.run(['javac','-encoding','UTF-8','--release','17','-cp',classpath,'-d',str(root/'lastleg-service/target/test-classes'),*sources],check=True)
result=subprocess.run([shutil.which('mvn'),' -T'.strip(),'1','-DforkCount=1','-DreuseForks=true','-pl','lastleg-service','surefire:test','-Dtest=FleetTripCalendarServiceTest,FleetTripCalendarMapperTest'],check=False)
raise SystemExit(result.returncode)
+1 -7
View File
@@ -1,12 +1,6 @@
# 单测试环境数据库空库初始化
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
客户“确认计费重”统一使用 `fl_inbound_fee` 最新已推送费用版本及其客户确认记录,不再维护独立重量确认表或接口。源建表定义、旧创建脚本与空库基线已移除;已有 TEST 先发布移除旧引用的 Firstleg 版本,再执行 `migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql` 删除 `fl_chargeable_weight_confirmation`,不修改费用、订单或测量数据。脚本存在不表示已执行或部署。
客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。
费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。
@@ -1,7 +0,0 @@
-- 排车新编号所需独立结构;仅新建序列表,不改写已有排车业务号或业务数据。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
@@ -1,108 +0,0 @@
-- MySQL 8 可重复执行增量:仓储转运到仓取货与本土卡车接入现有排车。
-- 目标数据库固定为 baiyue_lastleg;仅新增运输类型、原单关联及请求账本,不回填或删除业务数据。
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。
USE baiyue_lastleg;
SET @dispatch_transport_column_ddl = IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'task_type'
),
'SELECT 1',
'ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''任务类型:仓库派送、到仓取货、本土卡车'' AFTER dispatch_no'
);
PREPARE dispatch_transport_column_stmt FROM @dispatch_transport_column_ddl;
EXECUTE dispatch_transport_column_stmt;
DEALLOCATE PREPARE dispatch_transport_column_stmt;
-- 旧库兼容:整车主单跨客户,原来单客户排车头字段不再要求填写。
-- 如目标库已删除旧字段则跳过;已有客户值保留,不置空、不修改。
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'customer_id' AND is_nullable = 'NO'),
'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NULL COMMENT ''旧客户排车归属,整车主单不使用''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
-- 旧本土卡车结构兼容:新单沿用已有的起终点联系人及pickup_at/delivery_at字段。
-- 旧单字段保留原值,仅放宽空值约束,避免新单被已弃用字段阻断。
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_name' AND is_nullable = 'NO'),
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NULL COMMENT ''旧本土卡车联系人,新单使用起终点联系人''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_info' AND is_nullable = 'NO'),
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NULL COMMENT ''旧本土卡车联系方式,新单使用起终点联系方式''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
SET @dispatch_transport_legacy_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'requested_date' AND is_nullable = 'NO'),
'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NULL COMMENT ''旧本土卡车请求日期,新单使用取货和送货时间''',
'SELECT 1'
);
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
EXECUTE dispatch_transport_legacy_stmt;
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY (dispatch_no),
UNIQUE KEY uk_transport_trip (trip_no),
UNIQUE KEY uk_transport_source (active_order),
KEY idx_transport_order (customer_id, order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
PRIMARY KEY (trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY (dispatch_no, request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
SET @dispatch_transport_pool_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND column_name = 'source_looseboard_order_id'),
'SELECT 1',
'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''
);
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
EXECUTE dispatch_transport_pool_stmt;
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
SET @dispatch_transport_pool_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND index_name = 'idx_booking_looseboard_source'),
'SELECT 1',
'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id, source_looseboard_order_id, status)'
);
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
EXECUTE dispatch_transport_pool_stmt;
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
@@ -0,0 +1,15 @@
-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。
-- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
@@ -1,4 +0,0 @@
-- 客户“确认计费重”统一确认 fl_inbound_fee 的当前推送费用版本。
-- 先发布已移除旧重量确认接口及查询的 Firstleg 版本,再执行本脚本。
-- 仅删除旧独立重量确认记录,不修改费用快照、订单或测量数据。
DROP TABLE IF EXISTS baiyue_firstleg.fl_chargeable_weight_confirmation;
@@ -1,23 +0,0 @@
-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。
ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车' AFTER dispatch_no;
CREATE TABLE ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
@@ -1,13 +0,0 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
@@ -4437,6 +4437,20 @@ CREATE TABLE fl_inbound_split_item (
SET SESSION foreign_key_checks = 1;
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
-- 服务 schema:baiyue_file
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_file;
@@ -6842,18 +6856,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
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 (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,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 (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,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 (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,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 (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,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 (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,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 (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,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);
@@ -8383,4 +8385,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
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 (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables;
@@ -1,21 +0,0 @@
# 排车运输接入数据库变更汇总
执行文件:[lastleg-dispatch-transport-tasks.sql](lastleg-dispatch-transport-tasks.sql)。目标为 **MySQL 8 / baiyue_lastleg**,文件内已有 `USE baiyue_lastleg`。可整份重复执行;按 information_schema 判断字段及索引,按 IF NOT EXISTS 创建表。已在本机既有业务数据环境连续执行两次通过;未在远端环境执行。
| 对象 | 新增或调整 | 用途 |
|---|---|---|
| ll_dispatch_order.task_type | 新增 VARCHAR(32) NOT NULL DEFAULT DELIVERY | 区分仓库派送、到仓取货、本土卡车;旧单保留派送类型 |
| ll_dispatch_truck_task | 新表;dispatch_no、trip_no、customer_id、order_no、active_order | 真实排车关联原单;运输记录唯一、有效原单唯一;取消释放有效键,完成保留 |
| ll_dispatch_transport_create | 新表;trip_no、request_hash | 原安排请求摘要,避免重复建单及同键更换原单 |
| ll_dispatch_transport_command | 新表;dispatch_no、request_key、request_hash、evidence_json、created_at | 执行命令幂等、已确认完成凭证持久化 |
| ll_dispatch_daily_sequence | 既有正式日流水表,缺失时补建 | 统一 PC 日期与四位流水;不修改历史单号 |
| ll_dispatch_order.customer_id | 仅旧库存在且为 NOT NULL 时改为允许 NULL | 兼容整车跨客户主单;保留原值,不回填或置空 |
| ll_us_truck_order.contact_name / contact_info / requested_date | 仅旧库仍为 NOT NULL 时改为允许 NULL | 兼容现有原单起终点联系人及取送时间模型,保留历史字段和数据 |
| ll_fleet_external_booking.source_looseboard_order_id | 缺失时补 BIGINT NULL | 当前承运占用查询依赖,等同 V33;不创建散板业务 |
| ll_fleet_external_booking.idx_booking_looseboard_source | 缺失时补客户、原散板ID、状态联合索引 | 原单承运占用查询 |
所有新增表、字段均有中文注释。SQL不创建账号、不改角色权限、不删除表或记录、不改库存及历史单号,也不回填旧任务。
前置:目标库已具备当前整车排车、FleetTrip、仓储转运取货及本土卡车业务结构;本文件是本次运输接入增量,不替代这些模块的完整升级。执行完成后配套发布 Lastleg 和 Admin。迁移系统记录为 V32,承运占用依赖结构为 V33;直接交付执行时使用本份汇总 SQL 即可,不再重复手动执行两个版本文件。
本机验收中发现的旧字段约束与缺少日流水表已一并纳入。业务数据由正式 Gateway HTTP 创建,结构验证未写业务SQL。
+1 -128
View File
@@ -416,7 +416,6 @@ CREATE TABLE ll_dispatch_item (
CREATE TABLE ll_dispatch_order (
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
@@ -1866,12 +1865,6 @@ CREATE TABLE ll_order_no_daily_sequence (
PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
CREATE TABLE ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE ll_order_operation_log (
id bigint NOT NULL COMMENT '操作记录主键',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
@@ -3223,7 +3216,6 @@ CREATE TABLE ll_fleet_external_booking (
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
@@ -3238,7 +3230,6 @@ CREATE TABLE ll_fleet_external_booking (
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status),
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
@@ -4891,122 +4882,4 @@ CREATE TABLE ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
PRIMARY KEY (dispatch_id),
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT ck_owned_business_resource_mode CHECK (
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
photos_json JSON 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_owned_business_record_dispatch (dispatch_id),
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL)
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
operator_id VARCHAR(64) 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_owned_business_history_version (dispatch_id,record_version),
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
-- 运输原单接入现有排车,不产生仓内库存事实。
USE baiyue_lastleg;
CREATE TABLE ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
EXECUTE looseboard_demand_stmt;
DEALLOCATE PREPARE looseboard_demand_stmt;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 236 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
@@ -11,8 +11,6 @@ enum BusinessFileCategory {
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -73,11 +71,6 @@ enum BusinessFileCategory {
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
void validateOwner(long tenantId) {
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
}
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
@@ -1,14 +0,0 @@
openapi: 3.0.3
info:
title: 自有业务排车运营照片归属契约
version: 1.0.0
description: >-
自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。
复用InternalBusinessFileController,不增加公网File路径。
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
paths: {}
@@ -1,125 +0,0 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.file.Path;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OwnedBusinessPhotoFileTest {
private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO";
private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO";
@TempDir Path root;
@BeforeAll static void columns() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class);
}
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1});
for (long owner : new long[]{-1, 1, 900})
assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class);
for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""})
assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class);
verifyNoInteractions(files, storage);
}
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
var category = BusinessFileCategory.require(type);
for (String format : new String[]{"jpeg", "png"}) {
byte[] bytes = image(format);
var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes);
var saved = storage.store(0, "71", upload, category);
assertThat(storage.read(saved.objectKey())).isEqualTo(bytes);
}
for (var upload : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
MultipartFile oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class);
verify(oversized, never()).getInputStream();
}
/** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
byte[] bytes = image("png");
var row = new FileObjectEntity();
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length);
row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(bound(query, "status")).isEqualTo("ACTIVE");
assertThat(bound(query, "deleted")).isEqualTo(false);
return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type"))
&& "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null;
});
when(storage.read("owned-photo")).thenReturn(bytes);
assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type);
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes);
clearInvocations(storage);
for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING,
"FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) {
assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
private static Object bound(LambdaQueryWrapper<FileObjectEntity> query, String column) {
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
.matcher(query.getSqlSegment());
assertThat(matcher.find()).as("scope column %s", column).isTrue();
return query.getParamNameValuePairs().get(matcher.group(1));
}
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
return service;
}
private static byte[] image(String format) throws Exception {
var bytes = new ByteArrayOutputStream();
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
return bytes.toByteArray();
}
}
@@ -23,7 +23,6 @@ public interface MerchantWorkbenchFilterMapper {
FROM fl_inbound_order i
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL
AND i.process_status != 'CANCELLED' AND i.measure_review_status='APPROVED'
AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
@@ -64,13 +63,13 @@ public interface MerchantWorkbenchFilterMapper {
ORDER BY i.order_id
""")
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
/** 仅按本客户实际装柜片段读取进度;清关资料保存后立即显示已清关。 */
/** Only physically loaded fragments can expose container milestones to the customer. */
@Select("""
<script>
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
WHEN EXISTS (SELECT 1 FROM fl_container_document d
WHERE d.container_booking_id=owned.container_booking_id
AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL) THEN 3
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
WHERE n.container_booking_id=owned.container_booking_id
AND n.standard_node='PICKUP_READY' AND n.actual_at IS NOT NULL) THEN 3
WHEN EXISTS (SELECT 1 FROM fl_customs_container c
WHERE c.loading_plan_id=owned.loading_plan_id AND c.status='COMPLETED') THEN 2
ELSE 1 END AS stage
@@ -1,37 +0,0 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 客户工作台筛选事实的持久化入口。 */
@Repository
public class MerchantWorkbenchFilterRepository {
private final MerchantWorkbenchFilterMapper mapper;
/** 注入客户隔离的筛选查询。 */
public MerchantWorkbenchFilterRepository(MerchantWorkbenchFilterMapper mapper) {
this.mapper = mapper;
}
/** 查询物流主状态对应的订单范围。 */
public List<Long> byProcessStatuses(long tenantId, List<String> statuses) {
return mapper.byProcessStatuses(tenantId, statuses);
}
/** 只返回最新已推送费用仍待客户确认的订单。 */
public List<Long> pendingFees(long tenantId) {
return mapper.pendingFees(tenantId);
}
/** 查询待客户补齐报关资料的订单。 */
public List<Long> pendingCustoms(long tenantId) {
return mapper.pendingCustoms(tenantId);
}
/** 查询客户订单的实际履约片段。 */
public List<MerchantWorkbenchFilters.Fragment> progressFragments(long tenantId, List<Long> orderIds) {
return mapper.progressFragments(tenantId, orderIds);
}
}
@@ -1,30 +1,30 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class MerchantWorkbenchFilterService {
@Resource private MerchantWorkbenchFilterRepository repository;
@Resource private MerchantWorkbenchFilterMapper mapper;
@Resource private UnifiedInboundQueryService unified;
@Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights;
/** 精确详情只读进度;确认计费重待办统一使用最新已推送费用的客户确认状态。 */
/** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
if (request.orderIds() != null) {
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
progress(repository.progressFragments(request.tenantId(), request.orderIds())));
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
}
return new MerchantWorkbenchFilters.Response(
request.mainStatus() == null ? null : repository.byProcessStatuses(request.tenantId(),
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
unified.internalStatuses(request.mainStatus())),
repository.pendingFees(request.tenantId()), repository.pendingCustoms(request.tenantId()),
progress(repository.progressFragments(request.tenantId(), null)));
weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
progress(mapper.progressFragments(request.tenantId(), null)));
}
/** 将实装片段聚合为订单级履约进度。 */
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId));
return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> {
@@ -0,0 +1,57 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
/** 客户独立确认计费重,金额确认和主流程不受影响。 */
@Service
public class ChargeableWeightService {
private final WorkflowFactRepository repository;
/** 注入当前事实和确认历史。 */
public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; }
/** 客户仅能读取本订单已审核的计费重。 */
public List<WorkflowFacts.Weight> read(long tenant,long orderId) {
return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList();
}
/** 客户工作台按独立确认事实计算待办,不借用费用确认。 */
public List<Long> pendingOrders(long tenant) {
var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet<Long>();
for(int offset=0;offset<ids.size();offset+=500) for(var f:repository.facts(ids.subList(offset,Math.min(offset+500,ids.size()))))
if(f.tenantId()==tenant && eligible(f) && !weight(f).confirmed()) orders.add(f.orderId());
return List.copyOf(orders);
}
/** 值和测量轮次变化使旧确认失效,金额变化不影响计费重。 */
public static String fingerprint(WorkflowFacts.Fact f) {
try { String value=f.inboundId()+"|"+f.chargeableWeightKg().stripTrailingZeros().toPlainString()+"|"+f.measureCompletedAt();
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** 未审核或非正数不允许确认。 */
public static boolean eligible(WorkflowFacts.Fact f) { return "APPROVED".equals(f.reviewStatus()) && f.chargeableWeightKg()!=null && f.chargeableWeightKg().signum()>0; }
/** 只认可当前版本的确认。 */
public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) {
String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint());
return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null);
}
/** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */
@Transactional
public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) {
if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符");
var inbound=repository.lock(tenant,id);
if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在");
var replay=repository.command(tenant,operator,key);
if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重");
var facts=repository.facts(List.of(id));
if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重");
var f=facts.get(0);
if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过");
var current=weight(f);
if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认");
if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now()));
return weight(repository.facts(List.of(id)).get(0));
}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import jakarta.validation.Valid;
import java.util.List;
/** 客户计费重确认入口,独立于费用确认。 */
@RestController
@RequestMapping("/api/merchant/v1/first-leg")
public class MerchantChargeableWeightController {
private final ChargeableWeightService service;
/** 注入独立确认服务。 */
public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; }
/** 客户范围取认证上下文。 */
@GetMapping("/orders/{orderId}/chargeable-weights")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<List<WorkflowFacts.Weight>> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); }
/** 不触发费用确认与金额锁定。 */
@com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重")
@PostMapping("/chargeable-weights/{inboundId}/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<WorkflowFacts.Weight> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); }
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.workflow;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 柜事实与最新费用确认状态的只读 SQL 入口。 */
/** 柜事实和独立确认SQL入口。 */
@Mapper
public interface WorkflowFactMapper {
String ACTUAL="""
@@ -13,30 +13,29 @@ public interface WorkflowFactMapper {
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
""";
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,"
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,i.chargeable_weight_kg,i.measure_completed_at,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_customs_container c WHERE c.loading_plan_id=p.id AND c.status='COMPLETED')) customs_completed "
+"(SELECT w.fingerprint FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmation_fingerprint,"
+"(SELECT w.confirmed_at FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmed_at "
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
/** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */
@Select("""
<script>
SELECT i.tenant_id, i.order_id,
CASE WHEN f.customer_confirmed_at IS NULL THEN 'WAIT_WEIGHT_CONFIRM'
ELSE 'WEIGHT_CONFIRMED' END AS main_status
FROM fl_inbound_order i
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
WHERE i.deleted=0 AND i.process_status != 'CANCELLED'
AND i.measure_review_status='APPROVED' AND f.fee_status='SENT'
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id)))
AND i.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
</script>
""")
List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(@Param("ids") List<Long> ids);
/** 锁定客户自己的入仓单,串行确认与测量写入。 */
@Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE")
com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id);
/** 客户订单下全部独立入仓单。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id")
List<Long> orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
/** 客户工作台只读取审核通过且有正计费重的来源。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id")
List<Long> weightCandidates(@Param("tenantId") long tenantId);
/** 成员幂等键不允许跨确认对象复用。 */
@Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}")
WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key);
/** 独立记录,不修改费用或物流主状态。 */
@Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})")
int insert(WorkflowFacts.Confirmation value);
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 本地柜事实与费用确认状态的只读持久化入口。 */
/** 本地柜事实与确认记录的持久化入口。 */
@Repository
public class WorkflowFactRepository {
private final WorkflowFactMapper mapper;
@@ -9,8 +9,14 @@ public class WorkflowFactRepository {
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
/** 空候选不读取数据。 */
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
/** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */
public List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(List<Long> ids) {
return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids);
}
/** 锁定所属客户来源。 */
public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); }
/** 查询订单下独立来源。 */
public List<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
/** 客户计费重待确认候选。 */
public List<Long> weightCandidates(long tenant) { return mapper.weightCandidates(tenant); }
/** 读取成员确认命令。 */
public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); }
/** 追加确认历史。 */
public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); }
}
@@ -1,12 +1,17 @@
package com.baiyue.firstleg.workflow;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 订单业务页签的只读事实契约。 */
/** 页签事实与独立计费重确认契约。 */
public final class WorkflowFacts {
/** 仅承载事实查询契约,不允许实例化。 */
/** 仅承载查询及确认契约,不允许实例化。 */
private WorkflowFacts() {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,boolean customsCompleted) {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {}
public record Source(long tenantId,long inboundId) {}
public record Request(List<Source> sources) {}
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {}
public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {}
public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {}
}
@@ -22,12 +22,7 @@ public class WorkflowTabService {
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
// 确认页签复用已推送费用版本。
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
}
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) {
try {
@@ -51,23 +46,22 @@ public class WorkflowTabService {
}
return List.copyOf(result.values());
}
/** 分类为可重叠的物流待办/证据集合,客户确认由最新费用版本单独提供。 */
/** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM");
boolean signed="DELIVERED".equals(process);
// 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。
boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded();
if(!clearanceStage && !signed) {
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
if(!f.departed() && !signed) {
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
}
if(signed) keys.add("SIGNED");
if(clearanceStage && !signed) {
if(f.departed() && !signed) {
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
// 清关资料保存成功后才进入海外待办;出库事实优先于拆柜和排车待办。
if(f.clearanceUploaded() && o!=null) {
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
if(o!=null) {
if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
@@ -0,0 +1,13 @@
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
@@ -355,7 +355,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
post:
summary: 查询单一客户工作台履约筛选标识(Order内部)
description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
security: [{internalServiceToken: []}]
requestBody:
required: true
@@ -388,11 +388,11 @@ paths:
required: [feeOrderIds, customsOrderIds, progress]
properties:
matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}}
feeOrderIds: {type: array, description: 待客户确认最新已推送费用版本的订单ID, items: {type: integer, format: int64}}
feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}}
customsOrderIds: {type: array, items: {type: integer, format: int64}}
progress:
type: array
description: 实际装载片段投影;报关要求COMPLETED,目的港清关以本柜DESTINATION_CLEARANCE资料成功绑定file_id为准,不等待PICKUP_READY船节点。
description: 实际装载片段投影;报关要求COMPLETED,目的港清关要求PICKUP_READY实际时间,缺失节点不推测。
items:
type: object
required: [orderId, stage, partial]
@@ -1674,7 +1674,7 @@ components:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
nullable: true
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。
customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -0,0 +1,64 @@
openapi: 3.0.3
info: {title: 客户独立计费重确认, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights:
get:
summary: 客户读取本票全部独立入仓计费重
description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。
parameters:
- {name: orderId, in: path, required: true, schema: {type: integer, format: int64}}
responses:
'200':
description: 标准ApiResponse,认证客户无可确认来源时返回空数组
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
type: array
items: {$ref: '#/components/schemas/Weight'}
/api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm:
post:
summary: 客户独立确认当前计费重
description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [expectedFingerprint]
properties:
expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'}
responses:
'200':
description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/Weight'}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
Weight:
type: object
required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed]
properties:
inboundId: {type: string, description: 独立来源入仓单ID}
orderId: {type: string, description: 独立订单ID}
chargeableWeightKg: {type: number}
fingerprint: {type: string}
confirmed: {type: boolean}
confirmedAt: {type: string, format: date-time, nullable: true}
@@ -19,7 +19,6 @@ class MerchantWorkbenchFilterMapperTest {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))");
jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'");
jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)");
@@ -30,12 +29,11 @@ class MerchantWorkbenchFilterMapperTest {
jdbc.execute("ALTER TABLE fl_loading_plan_item ADD id BIGINT GENERATED BY DEFAULT AS IDENTITY");
jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(32))");
jdbc.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(32),picked_package_count INT)");
jdbc.execute("CREATE TABLE fl_container_document(container_booking_id BIGINT,document_type VARCHAR(40),file_id BIGINT)");
jdbc.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(64),actual_at TIMESTAMP)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class);
jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')");
jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)");
@@ -96,12 +94,6 @@ class MerchantWorkbenchFilterMapperTest {
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
// 其他柜、其他资料类型或未绑定文件不能视为清关资料提交成功。
jdbc.update("INSERT INTO fl_container_document VALUES(999,'DESTINATION_CLEARANCE',1),(100,'DELIVERY_ORDER',2),(100,'DESTINATION_CLEARANCE',NULL)");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=NULL");
jdbc.update("UPDATE fl_container_document SET file_id=3 WHERE document_type='DESTINATION_CLEARANCE' AND container_booking_id=100");
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3);
assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);});
jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'");
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
@@ -9,22 +9,25 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class MerchantWorkbenchFilterServiceTest {
/** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */
@Test void customerQueueUsesPublishedFeeConfirmation() {
var repository=mock(MerchantWorkbenchFilterRepository.class);
/** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */
@Test void customerQueueUsesIndependentWeightConfirmation() {
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"repository",repository);
when(repository.pendingFees(11)).thenReturn(List.of(101L));
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"weights",weights);
when(weights.pendingOrders(11)).thenReturn(List.of(101L));
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
assertThat(response.feeOrderIds()).containsExactly(101L);
verify(repository).pendingFees(11);
verify(weights).pendingOrders(11);
verify(mapper,never()).pendingFees(anyLong());
}
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
var mapper=mock(MerchantWorkbenchFilterRepository.class);
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var unified=mock(UnifiedInboundQueryService.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"repository",mapper);
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"unified",unified);
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -13,7 +15,8 @@ import static org.mockito.Mockito.*;
class WorkflowBoundaryTest {
/** 真实开船来源的测试事实。 */
private WorkflowFacts.Fact fact() {
return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false,false);
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
}
/** 下游漏来源或返回其他客户均整体失败。 */
@@ -33,23 +36,28 @@ class WorkflowBoundaryTest {
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
}
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
@Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
var service=new WorkflowTabService(repository,downstream);
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
verifyNoInteractions(downstream);
when(repository.facts(List.of(1L))).thenReturn(List.of(
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(11,1,false,false,false,false))));
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
/** 重测同值也要求重新确认,金额没有参与摘要。 */
@Test void repeatedMeasurementRequiresNewConfirmation() {
var original=fact();
var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
original.measureCompletedAt().plusSeconds(1),true,false,false,false,
ChargeableWeightService.fingerprint(original),LocalDateTime.now());
assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse();
}
/** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */
@Test void confirmationUsesAuthenticatedCustomerAndOperator() {
var service=mock(ChargeableWeightService.class);
var controller=new MerchantChargeableWeightController(service);
var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of());
var request=new WorkflowFacts.Confirm("a".repeat(64));
assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class);
assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class);
verifyNoInteractions(service);
var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of());
controller.read(merchant,101);
controller.confirm(merchant,1,"key",request);
verify(service).read(11,101);
verify(service).confirm(11,7,1,"key",request);
}
}
@@ -1,52 +1,42 @@
package com.baiyue.firstleg.workflow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
/** 取消回显、分柜隔离和资料页签回归。 */
/** 取消回显、分柜隔离和独立计费重版本回归。 */
class WorkflowBusinessTest {
/** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,false);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,false);
var completed=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,false);
var time=LocalDateTime.of(2026,10,19,10,0);
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
.contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(completed,"LOADED",null))
.containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
.contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
.containsExactly("SAILING_CLEARANCE");
.contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
.contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded))
.doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS");
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false);
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
}
/** 构造独立分柜来源。 */
private WorkflowFacts.Fact fact(long id,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false,false);
private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
}
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
@Test void cancellationRestoresDispatchQueue() {
var f=fact(1,true);
var f=fact(1,"100",null,true);
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
@@ -57,10 +47,17 @@ class WorkflowBusinessTest {
}
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
@Test void independentSlicesAndUploadedClearance() {
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
.containsExactly("SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
.containsExactly("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
}
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
@Test void independentWeightVersion() {
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
}
}
@@ -5,16 +5,16 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.mybatis.spring.SqlSessionTemplate;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import java.nio.file.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 不创建独立重量确认表,验证资料归属和最新费用版本驱动的确认流转。 */
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
class WorkflowFactPersistenceTest {
/** 测试建表不连接业务数据库。 */
@Test void clearanceAndPublishedFeeConfirmationUseCurrentFacts() throws Exception {
@Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
db.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
@@ -29,6 +29,7 @@ class WorkflowFactPersistenceTest {
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
@@ -38,16 +39,15 @@ class WorkflowFactPersistenceTest {
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
assertThat(containers.hasActualVesselDeparture(5)).isOne();
assertThat(containers.hasActualVesselDeparture(6)).isZero();
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
assertThat(containers.hasActualVesselDeparture(6)).isZero();
var repository=new WorkflowFactRepository(mapper);
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
assertThat(service.read(22,101)).isEmpty();
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
@@ -60,11 +60,10 @@ class WorkflowFactPersistenceTest {
db.update("INSERT INTO fl_customs_customer_document VALUES(10,11,'ACTIVE',8)");
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,11,10)");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();});
// 本票资料上传后进入出口报关汇总,报关完成事实独立驱动待清关。
// 本票上传后即可汇总,不必等同柜其他票或点击开始报关;完成报关后仍等待实际开船。
for (String status : List.of("READY_TO_START", "DECLARING", "COMPLETED")) {
db.update("UPDATE fl_customs_container SET status=? WHERE id=8", status);
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isTrue();
assertThat(mapper.facts(List.of(1L)).get(0).customsCompleted()).isEqualTo("COMPLETED".equals(status));
}
db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
@@ -93,42 +92,29 @@ class WorkflowFactPersistenceTest {
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
});
// 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。
var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class);
org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any()))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false))));
var tabs=new WorkflowTabService(repository,downstream);
var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity();
inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).containsExactly(101L);
// 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。
db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"))
.noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"));
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)");
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).containsExactly(101L);
assertThat(workbench.pendingFees(22)).containsExactly(102L);
db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1");
assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty();
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1");
var current=service.read(11,101).get(0);
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
// 父单仅作汇总时,不重复进入业务页签。
assertThat(service.read(11,101)).hasSize(1);
assertThat(service.read(11,103)).hasSize(1);
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3));
assertThat(service.read(11,101)).isEmpty();
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var child=service.read(11,103).get(0);
assertThat(child.confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
}
}
@@ -98,12 +98,6 @@ VALUES
(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),
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'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),
@@ -167,7 +161,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
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:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
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;
@@ -1,45 +0,0 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */
class FleetBusinessPermissionSeedSqlContractTest {
/** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */
@Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception {
String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(String sql:List.of(local,unified)) {
for(var scope:List.of("owned-business","outsourced-business")) {
long base=scope.equals("owned-business")?5107390L:5107400L;
assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'");
assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'");
assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'");
assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'");
}
}
}
/** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */
@Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception {
String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'",
"role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0");
}
/** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */
@Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception {
String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(long id:List.of(5107400L,5107401L,5107402L)) {
var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))");
}
}
}
-2
View File
@@ -17,8 +17,6 @@
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
-2
View File
@@ -85,8 +85,6 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -24,10 +24,6 @@
既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
### 自有车辆运营视图读取边界(2026-10-20)
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
### 平台自营统计边界
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
@@ -36,7 +32,7 @@
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环、fleet不反向导入dispatch/delivery、dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去这条聚合环。
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
@@ -9,14 +9,6 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
const ownedBusinessReadEdges = [
['model.OwnedBusinessModels', 'DispatchViews'],
['service.OwnedBusinessProjection', 'DispatchContracts'],
['service.OwnedBusinessQueryService', 'DispatchContracts'],
['service.OwnedBusinessQueryService', 'DispatchTypes'],
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
const report = analyze();
@@ -50,16 +42,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
assert.equal(policyClasses.length, 3);
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
const reviewed = JSON.parse(readFileSync(contract, 'utf8')).reviewedClassDependencies;
const commercialEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && /com\.baiyue\.lastleg\.(trucking|looseboard|procurement)\./.test(edge.target));
assert.ok(commercialEdges.every(edge => reviewed.some(allowed => allowed.source === edge.source && allowed.target === edge.target)), 'commercial integration only uses individually reviewed class references');
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', ...(commercialEdges.length ? ['procurement-trucking-looseboard'] : []), 'shared-technical']);
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']);
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters');
}
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false);
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);
@@ -69,54 +58,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'
|| [
'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels',
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
&& 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);
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
});
test('owned business reads only reviewed dispatch ports and cannot inherit persistence access', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.fleet.ownedbusiness.';
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
const edge = { source: prefix + 'service.OwnedBusinessQueryService', target: 'com.baiyue.lastleg.dispatch.' + target };
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
const unreviewed = { source: 'com.baiyue.lastleg.fleet.trip.FleetTripService', target: 'com.baiyue.lastleg.dispatch.port.DispatchDetailReader' };
report.classDependencies.push(unreviewed);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
});
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
});
function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
@@ -8,7 +8,6 @@ import java.time.LocalDateTime;
public class DispatchAggregate {
private Long id;
private String dispatchNo;
private String taskType;
private Long tripId;
private String overseasWarehouseId;
private String overseasWarehouseCode;
@@ -1,24 +0,0 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.springframework.stereotype.Component;
/** 只读委托既有排车应用服务,运营域不引用主流程实现或持久化实体。 */
@Component
public class DispatchDetailReadAdapter implements DispatchDetailReader {
private final DispatchAggregateService dispatch;
/** 复用唯一原排车详情提供者,不建立第二套查询规则。 */
public DispatchDetailReadAdapter(DispatchAggregateService dispatch) {
this.dispatch = dispatch;
}
/** 原单资格与可见性仍由原应用服务核对。 */
@Override
public DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo) {
return dispatch.detail(actor, dispatchNo);
}
}
@@ -14,11 +14,7 @@ public interface DispatchAggregateMapper {
<if test='q.dispatchNo != null'> AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%')</if>
<if test='q.vehiclePlate != null'> AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%')</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.customerId != null'> AND (
EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})
OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId})
OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId})
)</if>
<if test='q.customerId != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId})</if>
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
""";
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
@@ -37,12 +33,12 @@ public interface DispatchAggregateMapper {
/** 首次创建整车所有权及初始版本,不写customer_id。 */
@Insert("""
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code,
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code,
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
remark,created_by,created_at,updated_at)
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
@@ -9,21 +9,6 @@ import java.util.List;
@Mapper
public interface DispatchCandidateMapper {
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
String DEPARTED_PLAN =
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
"ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" +
"SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
"ON dl.id=da.line_id LEFT JOIN ll_order departure_order " +
"ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " +
"(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" +
"CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" +
"SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " +
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
String CONFIRMED_APPOINTMENT_AT =
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
@@ -56,7 +41,7 @@ public interface DispatchCandidateMapper {
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
String AVAILABLE_PLAN_BODY =
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
@@ -68,16 +53,16 @@ public interface DispatchCandidateMapper {
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
"CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
@@ -85,11 +70,11 @@ public interface DispatchCandidateMapper {
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
@@ -123,13 +108,12 @@ public interface DispatchCandidateMapper {
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
@@ -166,7 +150,7 @@ public interface DispatchCandidateMapper {
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
String SELECTED_PLAN_SQL = "<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
@@ -177,16 +161,16 @@ public interface DispatchCandidateMapper {
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
@@ -194,11 +178,11 @@ public interface DispatchCandidateMapper {
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
@@ -232,8 +216,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
@@ -278,8 +261,7 @@ public interface DispatchCandidateMapper {
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
"AND pos.hold_packages &gt; 0 AND pos.pallet_id &gt; 0 " +
"AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
@@ -1,22 +0,0 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
@Mapper
public interface DispatchNumberSequenceMapper {
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
int allocate(@Param("date") LocalDate date);
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -11,8 +11,6 @@ public interface DispatchSourceMapper {
long allocatedPackages, long pickedPackages, long version) { }
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
record PalletReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages,String inboundCode) { }
record PalletReturnedReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages) { }
record Claim(Long id, Long dispatchId, String draftToken) { }
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
record NativeApplication(String customerId,String relabelNo) { }
@@ -114,27 +112,6 @@ public interface DispatchSourceMapper {
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
/** 同客户整页实托一次读齐库存版本,仍保留原仓身份供服务校验。 */
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,"
+ "available_packages AS availablePackages,hold_packages AS holdPackages,allocated_packages AS allocatedPackages,"
+ "picked_packages AS pickedPackages,version FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<Stock> stocks(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */
@Select("<script>SELECT warehouse_pallet_id AS palletId,id,order_id AS orderId,inventory_position_id AS inventoryPositionId,"
+ "received_packages AS packages,inbound_code AS inboundCode FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
List<PalletReceipt> palletReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */
@Select("<script>SELECT p.pallet_id AS palletId,r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,"
+ "r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY r.id</script>")
List<PalletReturnedReceipt> palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
@@ -1,7 +0,0 @@
package com.baiyue.lastleg.dispatch.port;
/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */
public interface DispatchDetailReader {
/** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */
DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo);
}
@@ -29,7 +29,7 @@ public final class DispatchViews {
public record Page(List<ListRow> records, long total, long page, long size, long pages,
Statistics statistics, Instant snapshotAt) { }
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion,
ReviewState reviewState, Reviews reviews, Resources resources,
String warehouseId, String warehouseCode, String warehouseName,
Instant plannedDepartureAt, Instant departedAt, Counts counts,
@@ -56,7 +56,7 @@ public final class DispatchViews {
String sourceBusinessType, String sourceBusinessNo) { }
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion,
ReviewState reviewState, Reviews reviews, Counts counts,
boolean pickingReconciled, Map<Action, Gate> actions,
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
@@ -1,29 +0,0 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
@Repository
public class DispatchNumberSequence {
private final DispatchNumberSequenceMapper mapper;
/** 流水与排车主单使用相同服务数据库。 */
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(LocalDate date) {
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
mapper.allocate(date);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
return value;
}
}
@@ -75,24 +75,6 @@ public class DispatchSourceRepository {
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
/** 同一实托的退回实收不得从来源证明丢失。 */
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
/** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */
public record SourceFacts(List<DispatchSourceMapper.Stock> stock,List<DispatchSourceMapper.Receipt> receipts,
List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts) { }
/** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */
public java.util.Map<Long,SourceFacts> sourceFacts(String customer,List<Long> pallets) {
if(pallets.isEmpty())return java.util.Map.of();
var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId));
var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList())));
var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList())));
var result=new java.util.LinkedHashMap<Long,SourceFacts>();
for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())),
List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of()))));
return java.util.Collections.unmodifiableMap(result);
}
/** 释放已到期草稿键。 */
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
/** 锁定当前活动资源占用。 */
@@ -10,7 +10,6 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -32,8 +31,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@@ -50,7 +47,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
/** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
@@ -150,13 +146,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
long started=System.nanoTime();
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
long candidatesRead=System.nanoTime();
var groups=sourceRules.selectionGroups(rows);
long groupsRead=System.nanoTime();
var batchFacts=appointments.candidates(rows);
long batchesRead=System.nanoTime();
var result=new ArrayList<DispatchPlanningModels.Candidate>();
for(var row:rows) {
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
@@ -186,12 +178,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
}
long finished=System.nanoTime();
// 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。
if(finished-started>=1_000_000_000L)LOG.warn(
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
}
@@ -281,7 +267,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay = commands.replay(actor,null,identity);
if (replay.isPresent()) return replay.get();
long id=IdWorker.getId();
String number=numbers.next();
String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
@@ -536,10 +522,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
// 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。
// 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。
ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(),
context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at);
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
}
@@ -658,7 +640,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
d.status(),d.packageCount(),d.palletCount())).toList();
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
documents,orderDetails(cargo));
@@ -765,12 +747,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
var blocked=new EnumMap<Action,Gate>(Action.class);
for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
return Collections.unmodifiableMap(blocked);
}
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
for(var action:Action.values()) {
@@ -824,7 +800,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 列表不逐行读取明细、附件或派送。 */
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row));
}
@@ -1,33 +0,0 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */
@Component
public class DispatchNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private final DispatchNumberSequence sequences;
private final Clock clock;
/** 正式运行按统一业务时区取当天日期。 */
@Autowired
public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); }
/** 固定时钟用于核实跨午夜归零边界。 */
DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; }
/** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */
public String next() {
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
long value = sequences.next(date);
if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内");
return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value);
}
}
@@ -4,7 +4,6 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -35,64 +34,16 @@ public class DispatchService {
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
var reads=new CandidateReadScope(rows);
var result=new LinkedHashMap<String,SelectionGroup>();
for(var row:rows) {
String key=wholeSourceKey(selection(row,null));
if(result.containsKey(key))continue;
var group=sourceGroup(row,false,reads);
var group=sourceGroup(row,false);
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
}
return Collections.unmodifiableMap(result);
}
/** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */
private final class CandidateReadScope {
private final Map<String,Set<Long>> loaded=new HashMap<>();
private final Map<String,Map<Long,List<DispatchPalletCandidate>>> available=new HashMap<>();
private final Map<String,Map<Long,SourceFacts>> sourceFacts=new HashMap<>();
/** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */
private CandidateReadScope(List<DispatchPalletCandidate> rows) {
var pallets=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null)
pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId());
pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids)));
loadFacts(rows);
}
/** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */
private List<DispatchPalletCandidate> read(String warehouse,List<Long> pallets) {
var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>());
var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>());
var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList();
if(!missing.isEmpty()) {
var rows=wholeSources.physicalAvailable(warehouse,missing);
for(var row:rows)
byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row);
loadFacts(rows);
known.addAll(missing);
}
return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList();
}
/** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */
private void loadFacts(Collection<DispatchPalletCandidate> rows) {
var missing=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null
&& !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId()))
missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId());
missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>())
.putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets))));
}
/** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */
private SourceFacts facts(DispatchPalletCandidate row) {
return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失");
}
}
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
@@ -127,11 +78,6 @@ public class DispatchService {
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
return sourceGroup(seed,locked,null);
}
/** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) {
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
@@ -144,9 +90,7 @@ public class DispatchService {
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
reasons.addAll(facts.blockReasons());
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
.filter(id->id!=null && id>0).forEach(pallets::add);
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
@@ -165,8 +109,7 @@ public class DispatchService {
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
if(!pallets.isEmpty()) {
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
reads.read(seed.overseasWarehouseId(),List.copyOf(pallets));
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
for(var entry:buckets.entrySet()) {
@@ -187,8 +130,7 @@ public class DispatchService {
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
}
}
if(reads!=null)reads.loadFacts(rows.values());
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads));
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
var sources=new ArrayList<SelectionGroupSource>();
@@ -455,7 +397,7 @@ public class DispatchService {
}
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
var facts = new java.util.TreeMap<String,Object>();
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
@@ -467,15 +409,12 @@ public class DispatchService {
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
facts.put("cartonMarks",row.relabelCartonMarks());
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false),
wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
var stock=basis.stock();
var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
facts.put("stock",stock);
facts.put("receipts",basis.receipts());
facts.put("returnedReceipts",basis.returnedReceipts());
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
return wholeHashes.source(facts);
}
@@ -1,91 +0,0 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.pickup.*;
import com.baiyue.lastleg.fleet.trip.*;
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.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
/** 两类原单通过现有排车权限接入,禁止客户主体借同名权限调用。 */
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches/transport-tasks")
public class TransportTaskController {
private final TransportTaskService service;
private final FleetPickupService pickups;
private final FleetTripService trips;
private final FleetTripCompletionService completion;
/** 注入运输业务和现有现场执行能力。 */
public TransportTaskController(TransportTaskService service,FleetPickupService pickups,FleetTripService trips,FleetTripCompletionService completion){this.service=service;this.pickups=pickups;this.trips=trips;this.completion=completion;}
/** 自动汇入审核后的上门取货需求。 */
@GetMapping("/pickup-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> pickupCandidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query){operator(user);return ApiResponse.success(pickups.candidates(query));}
/** 自动汇入本土卡车待派车需求。 */
@GetMapping("/truck-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> truckCandidates(@AuthenticationPrincipal AuthenticatedUser user){operator(user);return ApiResponse.success(service.truckCandidates());}
/** 创建入仓取货排车。 */
@PostMapping("/pickup") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')")
public ApiResponse<?> pickup(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request){return ApiResponse.success(service.createPickup(operator(user),request));}
/** 创建本土卡车排车。 */
@PostMapping("/truck") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')")
public ApiResponse<?> truck(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody TransportTaskModels.TruckCreate request){return ApiResponse.success(service.createTruck(operator(user),request));}
/** 当前排车详情的运输执行面板。 */
@GetMapping("/{number}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));}
/** 分配或改派真实车辆司机。 */
@PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
/** 取消未发车任务,来源回到待安排。 */
@PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')")
public ApiResponse<?> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));}
/** 候选搜索复用统一资源占用真相。 */
@PostMapping("/{number}/resource-occupancy") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<?> occupancy(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.ResourceQuery query){operator(user);return ApiResponse.success(trips.resourceOccupancy(service.transport(number,false).getTripNo(),query));}
/** 实际任务结束后分别释放车辆与司机。 */
@PostMapping("/{number}/release") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> release(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetCompletionModels.Release request){return ApiResponse.success(completion.release(service.transport(number,false).getTripNo(),operator(user),request));}
/** 取货发车仅推进运输。 */
@PostMapping("/{number}/pickup/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
public ApiResponse<?> departPickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.depart(service.transport(number,false).getTripNo(),operator(user),request));}
/** 全部送仓后结束取货运输。 */
@PostMapping("/{number}/pickup/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.complete(service.transport(number,false).getTripNo(),operator(user),request));}
/** 凭证归属具体客户原单。 */
@PostMapping(value="/{number}/pickup/{customer}/{source}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> pickupFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@RequestPart MultipartFile file){return ApiResponse.success(pickups.upload(service.transport(number,false).getTripNo(),customer,source,operator(user),file));}
/** 确认实提箱数和原单凭证。 */
@PostMapping("/{number}/pickup/{customer}/{source}/picked") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Pickup request){return ApiResponse.success(pickups.picked(service.transport(number,false).getTripNo(),customer,source,operator(user),request));}
/** 送仓只开放原单收货门禁。 */
@PostMapping("/{number}/pickup/{customer}/{source}/arrive") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.arrive(service.transport(number,false).getTripNo(),customer,source,operator(user),request));}
/** 取货凭证同时核对当前排车和原单归属。 */
@GetMapping("/{number}/pickup/{customer}/{source}/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> pickupEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@PathVariable long fileId){
operator(user);var trip=service.transport(number,false);
if(pickups.tasks(trip.getTripNo()).stream().noneMatch(task->task.customerId().equals(customer)&&task.transferNo().equals(source)))throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前排车没有该原单");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(pickups.evidence(customer,source,fileId));
}
/** 本土卡车按真实运输发车。 */
@PostMapping("/{number}/truck/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
public ApiResponse<?> departTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"depart",request));}
/** 本土卡车提供运输完成凭证后结案。 */
@PostMapping("/{number}/truck/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> completeTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"complete",request));}
/** 本土卡车凭证绑定本次排车资源记录。 */
@PostMapping(value="/{number}/truck/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> truckFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@RequestPart MultipartFile file){return ApiResponse.success(service.upload(number,operator(user),file));}
/** 完成凭证经排车归属授权后下载。 */
@GetMapping("/{number}/truck/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable long fileId){operator(user);return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=transport-evidence").body(service.evidence(number,fileId));}
/** 认证身份来自网关安全上下文。 */
private static String operator(AuthenticatedUser user){
if(user==null || user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以操作排车");return user.userId().toString();
}
}
@@ -1,42 +0,0 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import org.apache.ibatis.annotations.*;
/** 运输任务与现有排车主单的一对一关系及本土卡车原单关联。 */
@Mapper
public interface TransportTaskMapper {
/** 已成功命令持久化的凭证引用可在刷新后恢复。 */
@Select("SELECT evidence_json FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} ORDER BY created_at,request_key")
java.util.List<String> evidence(String number);
/** 运输命令独立幂等账本,摘要包含动作与版本。 */
@Select("SELECT request_hash FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} AND request_key=#{key}")
String commandHash(@Param("number") String number,@Param("key") String key);
/** 仅在执行成功同事务记账。 */
@Insert("INSERT INTO ll_dispatch_transport_command(dispatch_no,request_key,request_hash,evidence_json) VALUES(#{number},#{key},#{hash},#{evidence})")
int command(@Param("number") String number,@Param("key") String key,@Param("hash") String hash,@Param("evidence") String evidence);
/** 创建键关联原始请求摘要,避免修改来源后重放。 */
@Select("SELECT request_hash FROM ll_dispatch_transport_create WHERE trip_no=#{trip}")
String createHash(String trip);
/** 保存首次本土卡车安排内容。 */
@Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})")
int createHashSave(@Param("trip") String trip,@Param("hash") String hash);
/** 仅返回已完成原单收货分配且没有承运安排的本土卡车需求。 */
@Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status='PENDING_DISPATCH' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100")
java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates();
/** 原取货创建请求重放时恢复同一排车号。 */
@Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}")
DispatchAggregate byTrip(long trip);
/** 原单仅允许一个有效承运任务,取消后保留历史。 */
@Insert("INSERT INTO ll_dispatch_truck_task(dispatch_no,trip_no,customer_id,order_no,active_order) VALUES(#{dispatchNo},#{tripNo},#{customerId},#{orderNo},CONCAT(#{customerId},':',#{orderNo}))")
int insert(TransportTaskModels.TruckLink row);
/** 按排车号读取本土卡车关联。 */
@Select("SELECT dispatch_no,trip_no,customer_id,order_no FROM ll_dispatch_truck_task WHERE dispatch_no=#{number}")
TransportTaskModels.TruckLink truck(String number);
/** 取消任务解除有效单唯一约束,历史关联不删除。 */
@Update("UPDATE ll_dispatch_truck_task SET active_order=NULL WHERE dispatch_no=#{number}")
int cancel(String number);
/** 查询排车唯一资源记录。 */
@Select("SELECT trip_no FROM ll_fleet_trip WHERE id=#{id}")
String tripNo(long id);
}
@@ -1,23 +0,0 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.fleet.trip.FleetTripModels;
import com.baiyue.lastleg.fleet.pickup.FleetPickupModels;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.util.List;
/** 排车列表中的到仓取货与本土卡车运输命令。 */
public final class TransportTaskModels {
/** 仅承载契约。 */
private TransportTaskModels() { }
public record TruckCreate(@NotBlank String customerId,@NotBlank String orderNo,
@NotNull @Positive Long warehouseId,@NotNull Instant plannedDepartureAt,
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence) { }
public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { }
}
@@ -1,84 +0,0 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */
@Service
public class TransportTaskProjection {
private final DispatchAggregateRepository roots;
private final TransportTaskRepository links;
private final UsTruckOrderRepository trucks;
private final DispatchNumberGenerator numbers;
private final ObjectMapper json;
/** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */
public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) {
this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json;
}
/** 注册真实排车号;来源行锁和唯一 trip_id 防止重复建单。 */
public DispatchAggregate register(FleetTripEntity trip,int items,int pallets,int packages,String operator) {
var existing=links.byTrip(trip.getId());if(existing!=null)return existing;
var row=new DispatchAggregate();row.setId(IdWorker.getId());row.setDispatchNo(numbers.next());row.setTaskType(trip.getTripPurpose());
row.setTripId(trip.getId());row.setOverseasWarehouseId(trip.getWarehouseId().toString());
row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName());
row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L);
row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L);
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null);
row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review));
resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());
row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages);
row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator);
row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row);
roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row;
}
/** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */
@EventListener
public void changed(TransportTripChanged event) {
var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return;
var found=links.byTrip(trip.getId());if(found==null)return;
var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion();
row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt());
row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE");
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";});
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败");
roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt());
var link=links.truck(row.getDispatchNo());if(link==null)return;
String target=switch(trip.getStatus()){case "CANCELLED"->"PENDING_DISPATCH";case "IN_TRANSIT"->"IN_TRANSIT";case "COMPLETED"->"COMPLETED";default->"DISPATCHED";};
transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion());
if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo());
}
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
String from=order.getStatus();if(target.equals(from))return;
boolean allowed=("PENDING_DISPATCH".equals(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target));
if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致");
order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order);
var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer);
history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key);
history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history);
}
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
private void resources(DispatchAggregate row,FleetTripEntity trip) {
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()),
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null))));
}
/** 保存明确的业务快照,失败时终止事务。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}}
}
@@ -1,34 +0,0 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import org.springframework.stereotype.Repository;
/** 运输关联持久化边界,不在此决定业务状态。 */
@Repository
public class TransportTaskRepository {
private final TransportTaskMapper mapper;
/** 注入本域 Mapper。 */
public TransportTaskRepository(TransportTaskMapper mapper) { this.mapper=mapper; }
/** 已确认完成凭证引用。 */
public java.util.List<String> evidence(String number){return mapper.evidence(number);}
/** 读取已成功执行的运输命令摘要。 */
public String commandHash(String number,String key){return mapper.commandHash(number,key);}
/** 成功后记账。 */
public void command(String number,String key,String hash,String evidence){mapper.command(number,key,hash,evidence);}
/** 读取原创建内容摘要。 */
public String createHash(String trip){return mapper.createHash(trip);}
/** 保存创建内容摘要。 */
public void createHash(String trip,String hash){mapper.createHashSave(trip,hash);}
/** 按资源记录恢复排车主单。 */
public DispatchAggregate byTrip(long trip) { return mapper.byTrip(trip); }
/** 保存原本土卡车单关联。 */
public void insert(TransportTaskModels.TruckLink row) { mapper.insert(row); }
/** 读取原单关联。 */
public TransportTaskModels.TruckLink truck(String number) { return mapper.truck(number); }
/** 取消后允许原单重新安排。 */
public void cancel(String number) { mapper.cancel(number); }
/** 解析主单对应资源业务号。 */
public String tripNo(long id) { return mapper.tripNo(id); }
/** 最早的一百票待派车原单,安排成功后自动移出候选。 */
public java.util.List<com.baiyue.lastleg.trucking.entity.UsTruckOrder> candidates() { return mapper.candidates(); }
}
@@ -1,158 +0,0 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.fleet.pickup.*;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.time.*;
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */
@Service
public class TransportTaskService {
private final DispatchAggregateRepository roots;
private final TransportTaskRepository links;
private final TransportTaskProjection projection;
private final FleetTripRepository tripRepo;
private final FleetTripService trips;
private final FleetPickupService pickups;
private final FleetPickupRepository pickupFacts;
private final UsTruckOrderRepository trucks;
private final FleetPoolOrderQueries pool;
private final DriverService drivers;
private final FleetTransportVehicleGuard vehicles;
private final TripOperationsEvidenceGateway files;
private final ObjectMapper json;
/** 复用已有资格、占用、原单及凭证能力。 */
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
UsTruckOrderRepository trucks,FleetPoolOrderQueries pool,DriverService drivers,FleetTransportVehicleGuard vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) {
this.roots=roots;this.links=links;this.projection=projection;this.tripRepo=tripRepo;this.trips=trips;this.pickups=pickups;this.pickupFacts=pickupFacts;
this.trucks=trucks;this.pool=pool;this.drivers=drivers;this.vehicles=vehicles;this.files=files;this.json=json;
}
/** 待排本土卡车仅展示未被独立排车或拼车承运占用的原单。 */
public List<UsTruckOrder> truckCandidates(){return links.candidates();}
/** 同仓取货沿用原任务规则,并注册一个真实排车主单。 */
@Transactional
public TransportTaskModels.Detail createPickup(String operator,FleetPickupModels.Create request) {
var created=pickups.create(operator,request);var trip=tripRepo.lockTrip(created.trip().tripNo());
var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator);
return detail(row.getDispatchNo());
}
/** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */
@Transactional
public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) {
String hash=hash(write(request));var prior=tripRepo.findCreated(operator,request.idempotencyKey());
if(prior!=null)return replayCreate(prior,hash);
var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在");
prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash);
pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId());
if(!"PENDING_DISPATCH".equals(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待派车且未安排承运的本土卡车单可以接入排车");
if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址");
var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(),
List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey()));
var trip=tripRepo.lockTrip(created.trip().tripNo());var row=projection.register(trip,1,value(order.getPalletCount()),value(order.getPackageCount()),operator);
links.insert(new TransportTaskModels.TruckLink(row.getDispatchNo(),trip.getTripNo(),order.getCustomerId(),order.getOrderNo()));links.createHash(trip.getTripNo(),hash);
projection.transition(order.getCustomerId(),order.getOrderNo(),"DISPATCHED","TRANSPORT_ASSIGN",operator,"接入排车:"+row.getDispatchNo(),request.idempotencyKey());
return detail(row.getDispatchNo());
}
/** 重放只返回同一原始请求,不受后续原单状态推进影响。 */
private TransportTaskModels.Detail replayCreate(FleetTripEntity trip,String hash) {
if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !hash.equals(links.createHash(trip.getTripNo())))throw conflict("同一安排请求不能更换来源或时间");
var row=links.byTrip(trip.getId());if(row==null)throw conflict("原安排尚未完成,请重试");return detail(row.getDispatchNo());
}
/** 通过排车号读取运输执行,不要求访问已隐藏的管理页面。 */
@Transactional(readOnly=true)
public TransportTaskModels.Detail detail(String number) {
var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number);
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList());
}
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
/** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */
private List<Long> evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->{try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference<List<Long>>(){}).stream();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}).distinct().toList();}
/** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */
public FleetTripEntity transport(String number,boolean lock) {
var root=roots.find(number);if(root==null)throw missing("排车单不存在");
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务");
String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip;
}
/** 分配实际资源,容量超限时整次分配回滚。 */
@Transactional
public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) {
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
}
/** 取消运输并保留原单、排车及资源审计。 */
@Transactional
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
var trip=transport(number,true);trips.cancel(trip.getTripNo(),operator,request);return detail(number);
}
/** 本土卡车发车和完成不制造仓库收发货记录。 */
@Transactional
public TransportTaskModels.Detail truckCommand(String number,String operator,String action,TransportTaskModels.Command request) {
var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()))throw conflict("当前不是本土卡车运输任务");
String hash=hash(action+":"+write(request));String prior=links.commandHash(number,request.idempotencyKey());
if(prior!=null){if(!prior.equals(hash))throw conflict("同一请求标识不能提交不同内容");return detail(number);}
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()))throw conflict("运输任务已变化,请刷新后重试");
var order=truck(number);
if("depart".equals(action)) {
if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车");
requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
} else if("complete".equals(action)) {
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车");
if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty())throw conflict("请上传至少一份本次运输完成凭证");
for(long id:request.evidenceFileIds())files.metadata(trip.getId(),id);
trip.setStatus("COMPLETED");trip.setCompletedAt(now());
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
} else throw conflict("不支持的运输动作");
trips.recordExecution(trip,request.recordVersion(),"LOCAL_TRUCK_"+action.toUpperCase(Locale.ROOT),request.reason(),operator);
links.command(number,request.idempotencyKey(),hash,write(request.evidenceFileIds()==null?List.of():request.evidenceFileIds()));return detail(number);
}
/** 上传只接受当前任务执行期间的真实文件,文件服务强制归属此资源记录。 */
@Transactional
public com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile upload(String number,String operator,MultipartFile file) {
var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前任务不能上传运输凭证");
if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw conflict("请上传不超过10MB的凭证");return files.upload(trip.getId(),operator,file);
}
/** 原单在执行时锁定,不能通过普通原单手工推进绕过排车。 */
private UsTruckOrder truck(String number){var link=links.truck(number);if(link==null)throw missing("本土卡车关联不存在");var order=trucks.lockNo(link.customerId(),link.orderNo());if(order==null)throw missing("本土卡车原单不存在");return order;}
/** 发车再次核对占用及证照,不把候选阶段校验当成执行资格。 */
private void requireResources(FleetTripEntity trip) {
if(trip.getVehicleId()==null || trip.getDriverId()==null || !pickupFacts.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("请先分配有效车辆和司机");
vehicles.validate(trip);
var driver=drivers.requireDispatchable(trip.getDriverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
}
/** 原单实收托量、申报重量和体积均不得超过已分配车辆能力。 */
private void capacity(FleetTripEntity trip,UsTruckOrder order) {
if(trip.getMaxPalletSpaces()==null || value(order.getPalletCount())>trip.getMaxPalletSpaces() || trip.getMaxLoadKg()==null || trip.getMaxVolumeCbm()==null
|| (order.getTotalWeightKg()!=null && order.getTotalWeightKg().compareTo(trip.getMaxLoadKg())>0)
|| (order.getTotalVolumeCbm()!=null && order.getTotalVolumeCbm().compareTo(trip.getMaxVolumeCbm())>0))throw conflict("本土卡车货量超过所选车辆托位、载重或容积");
}
/** 可空业务数量按零显示,不产生虚构实收。 */
private static int value(Integer value){return value==null?0:value;}
/** 固定 UTC 记录执行时刻。 */
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).withNano(0);}
/** 稳定序列化请求摘要。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
/** 不在账本保存原始隐私内容。 */
private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
/** 返回业务冲突。 */
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
/** 返回不可访问资源。 */
private static ResponseStatusException missing(String value){return new ResponseStatusException(HttpStatus.NOT_FOUND,value);}
}
@@ -1,29 +0,0 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import org.apache.ibatis.annotations.*;
import java.util.Collection;
import java.util.List;
@Mapper
public interface FleetLooseboardDemandMapper {
/** 分页读取未匹配且没有独立实物履约的客户散板需求。 */
@Select("<script>SELECT o.* FROM ll_local_looseboard_order o WHERE o.status='PENDING_ACCEPTANCE' AND o.lastleg_order_id IS NULL "
+ "AND NOT EXISTS (SELECT 1 FROM ll_fleet_external_booking b WHERE b.customer_id=o.customer_id AND b.source_looseboard_order_id=o.id AND b.status!='CANCELLED') "
+ "<if test='keyword != null and keyword != &quot;&quot;'>AND (o.order_no LIKE CONCAT('%',#{keyword},'%') OR o.origin LIKE CONCAT('%',#{keyword},'%') OR o.destination LIKE CONCAT('%',#{keyword},'%'))</if> "
+ "ORDER BY o.created_at DESC,o.id DESC</script>")
Page<LocalLooseboardOrder> pending(Page<LocalLooseboardOrder> page,@Param("keyword") String keyword);
/** 原单锁内查询生效匹配,防止跨车次重复接单。 */
@Select("SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customer} AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE")
Integer active(@Param("customer") String customer,@Param("id") long id);
/** 保存预订不可变来源,取消后保留历史。 */
@Update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=#{id} WHERE id=#{booking} AND customer_id=#{customer} AND source_looseboard_order_id IS NULL")
int attach(@Param("booking") long booking,@Param("customer") String customer,@Param("id") long id);
/** 锁定当前预订对应的同客户原单。 */
@Select("SELECT o.* FROM ll_local_looseboard_order o JOIN ll_fleet_external_booking b ON b.source_looseboard_order_id=o.id AND b.customer_id=o.customer_id WHERE b.id=#{booking} AND b.customer_id=#{customer} FOR UPDATE")
LocalLooseboardOrder source(@Param("booking") long booking,@Param("customer") String customer);
/** 批量读取草稿或取消记录的原单编号。 */
@Select("<script>SELECT b.id AS booking_id,o.order_no AS business_order_no FROM ll_fleet_external_booking b JOIN ll_local_looseboard_order o ON o.id=b.source_looseboard_order_id AND o.customer_id=b.customer_id WHERE b.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<FleetPoolOrderLink> sourceNumbers(@Param("ids") Collection<Long> ids);
}
@@ -1,28 +0,0 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
@Repository
public class FleetLooseboardDemandRepository {
private final FleetLooseboardDemandMapper demands;
private final LocalLooseboardOrderMapper orders;
/** 注入需求查询和原单仓储。 */
public FleetLooseboardDemandRepository(FleetLooseboardDemandMapper demands,LocalLooseboardOrderMapper orders) { this.demands=demands;this.orders=orders; }
/** 分页读取待匹配原单。 */
public Page<LocalLooseboardOrder> pending(int page,int size,String keyword) { return demands.pending(Page.of(page,size),keyword); }
/** 锁定客户原单。 */
public LocalLooseboardOrder lock(String customer,String number) { return orders.lockNo(customer,number); }
/** 核对生效匹配。 */
public boolean active(String customer,long id) { return demands.active(customer,id)!=null; }
/** 关联不可变来源。 */
public int attach(long booking,String customer,long id) { return demands.attach(booking,customer,id); }
/** 锁定匹配来源。 */
public LocalLooseboardOrder source(long booking,String customer) { return demands.source(booking,customer); }
/** 批量读取原单编号。 */
public List<FleetPoolOrderLink> sourceNumbers(Collection<Long> ids) { return demands.sourceNumbers(ids); }
}
@@ -1,52 +0,0 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.Objects;
@Service
public class FleetLooseboardDemandService {
private final FleetLooseboardDemandRepository repository;
/** 注入原单需求仓储。 */
public FleetLooseboardDemandService(FleetLooseboardDemandRepository repository) { this.repository=repository; }
/** 已授权平台入口读取待匹配原单。 */
@Transactional(readOnly=true)
public PageResult<Demand> pending(int page,int size,String keyword) {
var rows=repository.pending(page,size,keyword);
return new PageResult<>(rows.getRecords().stream().map(Demand::from).toList(),rows.getCurrent(),rows.getSize(),rows.getTotal(),rows.getPages());
}
public record Demand(String orderNo,String customerId,long version,String origin,String destination,String productName,
Integer estimatedPalletCount,Integer packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm,
String currency,String originContactName,String originContactPhone,String remark) {
/** 仅输出匹配所需资料,不泄露原单幂等键与请求哈希。 */
static Demand from(LocalLooseboardOrder o) { return new Demand(o.getOrderNo(),o.getCustomerId(),o.getVersion(),o.getOrigin(),o.getDestination(),o.getProductName(),o.getEstimatedPalletCount(),o.getPackageCount(),o.getTotalWeightKg(),o.getTotalVolumeCbm(),o.getCurrency(),o.getOriginContactName(),o.getOriginContactPhone(),o.getRemark()); }
}
/** 锁定原单并校验客户、版本与唯一匹配,不预留容量。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public LocalLooseboardOrder prepare(String customer,String type,String number,Long version) {
if(number==null||number.isBlank()) { if(version!=null)throw conflict("来源版本必须与散板原单一起提交"); return null; }
if(!"LOCAL_LOOSEBOARD".equals(type))throw conflict("散板原单只能匹配本土散板承运");
var order=repository.lock(customer,number);
if(order==null||version==null||!Objects.equals(version,order.getVersion()))throw conflict("散板原单不存在或已变化,请刷新待匹配需求");
if(!"PENDING_ACCEPTANCE".equals(order.getStatus())||order.getLastlegOrderId()!=null||repository.active(customer,order.getId()))throw conflict("散板原单已有承运或独立履约,请核对原单");
return order;
}
/** 原单锁保持到事务提交,跨车次匹配只允许成功一次。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void attach(long booking,LocalLooseboardOrder order) {
if(order!=null&&repository.attach(booking,order.getCustomerId(),order.getId())!=1)throw conflict("原单匹配失败,请刷新重试");
}
/** 匹配后禁止改写业务类型或替换来源,允许省略已有来源参数。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void validateEdit(long booking,String customer,String type,String number) {
var source=repository.source(booking,customer);
if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(type)||number!=null&&!number.equals(source.getOrderNo())))throw conflict("已匹配散板原单不能替换,请取消登记后重新匹配");
if(source==null&&number!=null&&!number.isBlank())throw conflict("请从待匹配散板需求创建新的登记");
}
/** 返回业务冲突。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
}
@@ -20,7 +20,6 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service
public class FleetPoolOrderBridge {
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
private final FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json;
@@ -48,20 +47,16 @@ public class FleetPoolOrderBridge {
validate(in);
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var source=demands.source(in.bookingId(),in.customerId());
if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(in.businessType())||!"PENDING_ACCEPTANCE".equals(source.getStatus())||source.getLastlegOrderId()!=null))throw conflict("散板原单履约状态已变化");
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId());
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo());
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId());
row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()));
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now);
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if(source!=null) {
source.setStatus("WAITING_INBOUND");source.setVersion(source.getVersion()+1);source.setUpdatedAt(now);repository.updateLoose(source);
} else if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportType(transport);
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
@@ -84,7 +79,7 @@ public class FleetPoolOrderBridge {
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
}
repository.insertLink(row);history(row,source==null?null:"PENDING_ACCEPTANCE",row.getFulfillmentStage(),operator,source==null?"确认承运并建立商业委托":"原单匹配车次并确认承运",now);
repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
return ref(row);
}
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
@@ -182,7 +177,6 @@ public class FleetPoolOrderBridge {
/** 批量读取预订的业务编号。 */
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
demands.sourceNumbers(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));
repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
}
static void validate(ConfirmedBooking in) {
@@ -38,9 +38,7 @@ public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND dispatch_no=#{no} ORDER BY id FOR UPDATE")
List<FleetPoolOrderLink> lockDispatch(@Param("customerId") String customerId,@Param("no") String no);
/** 原单锁内使用当前读,避免等待匹配提交后仍按旧快照放行独立修改。 */
@Select("SELECT COALESCE((SELECT 1 FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id} LIMIT 1 FOR UPDATE),0) + "
+ "COALESCE((SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customerId} AND #{type}='LOCAL_LOOSEBOARD' AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE),0)")
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}")
int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long id);
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no}")
int linkedIncoming(@Param("customerId") String customerId,@Param("no") String no);
@@ -1,80 +0,0 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
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.multipart.MultipartFile;
/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business")
public class OutsourcedBusinessController {
private final OutsourcedBusinessService service;
private final OwnedBusinessSupport access;
/** 会话只转真实平台操作者及其原权限集合。 */
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
/** 同筛选分页和完整阶段统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
/** 列表权限包含详情,不借原排车或自有写权限。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
/** 原主单和本页附加历史保持各自版本字段。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
}
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
}
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
}
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.content());
}
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
}
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
}
@@ -1,48 +0,0 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
public final class OutsourcedBusinessModels {
/** DTO容器不允许实例化。 */
private OutsourcedBusinessModels() {}
public static final String LIST="platform:fleet:outsourced-business:list";
public static final String EDIT="platform:fleet:outsourced-business:edit";
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
public record PhotoSummary(int loadingCount) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
public record HistoryPage(List<History> records,int page,int size,int total) {}
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
}
@@ -1,94 +0,0 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
@Service
public class OutsourcedBusinessService {
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
}
/** 三个页签读取同一原排车详情和外配附加记录。 */
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
var result=queries.history(actor,number,page,size,SCOPE);
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
}
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
var ops=request.operations();
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
}
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
}
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
if(request==null)throw bad("照片绑定请求缺失");
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
}
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
}
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
var original=photos.command(actor,number,key,SCOPE);Object result=null;
if(original.result()!=null)result=switch(original.action()) {
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
default->throw conflict("原命令类型不属于外配业务");
};
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
}
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
private Detail detail(OwnedBusinessModels.Detail detail) {
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
}
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
private Row row(OwnedBusinessModels.Row r,String carrierName) {
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
}
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
private Operations operations(OwnedBusinessModels.Operations ops) {
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
return new Operations(ops.money(),ops.arrivals());
}
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
private Photo photo(OwnedBusinessModels.Photo p) {
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
}
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
}
@@ -1,78 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
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.multipart.MultipartFile;
/** 只读原排车装车后事实,仅附加登记和照片可写。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/owned-business")
public class OwnedBusinessController {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessSupport access;
/** Controller不直调Repository/Mapper,身份只取会话。 */
public OwnedBusinessController(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
this.queries=queries;this.records=records;this.photos=photos;this.access=access;
}
/** 列表和完整统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(queries.list(access.actor(user),query));}
/** list权限包含独立详情。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(queries.detail(access.actor(user),dispatchNo));}
/** 原排车和附加历史在同一快照分页,不逐条GET。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
return ApiResponse.success(queries.history(access.actor(user),dispatchNo,page,size));
}
/** 金额可以更正/清空,但不推进主单或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(records.save(access.actor(user),dispatchNo,request));
}
/** 上传仅准备File,关联保存另有独立原键。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(photos.upload(access.actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定失败保留fileId和原请求,不重新上传。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(photos.bind(access.actor(user),dispatchNo,slot,request));
}
/** 附件只允许当前或精确历史引用读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
var result=photos.content(access.actor(user),dispatchNo,slot,fileId,revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.content());
}
/** 不自动补写或向别的账号披露原命令。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey));
}
}
@@ -1,86 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.LocalDateTime;
import java.util.Set;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
/** File保存照片,本域另行授权原排车及分类引用。 */
@Component
public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
public static final long MAX_SIZE=10L*1024*1024;
private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}";
private final RestClient client;
private final String token;
/** 沿既有内部File客户端,保持外部访问域封闭。 */
public HttpOwnedBusinessFileGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token;
}
/** MIME和字节签名一起校验,File仍执行其权威校验。 */
public static void validate(Upload upload) {
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content()))
throw bad("照片须为非空真实JPG/PNG,最大10MiB");
}
/** 使用原图片字节,未知结果不能换键或自动重传。 */
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
validate(upload);var body=new MultipartBodyBuilder();
body.part("file",new ByteArrayResource(upload.content()){
/** 保留本轮原文件名,不生成占位证据。 */
@Override public String getFilename(){return upload.fileName();}
}).contentType(MediaType.parseMediaType(upload.contentType()));
var result=client.post().uri(PATH,slot.businessType(),dispatchId).header("X-Internal-Service-Token",token)
.header("X-Operator-Id",operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().body(Envelope.class);
return verified(result,dispatchId,slot,null);
}
/** 不能借另一客户、另一分类或另一排车的文件授权。 */
@Override public Evidence metadata(long dispatchId,PhotoSlot slot,long fileId) {
return verified(client.get().uri(PATH+"/{file}",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(Envelope.class),dispatchId,slot,fileId);
}
/** 防止把错误页或其他内容冒充图片返回。 */
@Override public byte[] content(long dispatchId,PhotoSlot slot,long fileId) {
var info=metadata(dispatchId,slot,fileId);
byte[] bytes=client.get().uri(PATH+"/{file}/content",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(byte[].class);
if(bytes==null || bytes.length!=info.sizeBytes() || !signature(info.contentType(),bytes))throw conflict("图片内容与元数据不一致");
return bytes;
}
/** 所有归属及内容元数据必须严格匹配原dispatch内部ID。 */
public static Evidence verified(Envelope response,long dispatchId,PhotoSlot slot,Long expected) {
if(response==null || !"200".equals(response.code()) || response.data()==null)throw conflict("照片服务未返回明确成功");
var m=response.data();
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType())
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
}
/** 不放开PDF、SVG或任意image类型。 */
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);}
/** 使用真实文件头,不转换或伪造证据图片。 */
private static boolean signature(String type,byte[] bytes) {
if(bytes==null)return false;
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
}
/** 基础设施错误不反向依赖应用Service,保持单向包依赖。 */
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 不明确的文件结果保留为业务冲突,由上传账本记录。 */
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
public record Envelope(String code,Metadata data) {}
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes,LocalDateTime createdAt) {}
}
@@ -1,15 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
/** 本页平台图片受控代理,不接收浏览器指定的内部主键或业务类型。 */
public interface OwnedBusinessFileGateway {
record Upload(String fileName,String contentType,byte[] content) {}
record Evidence(long fileId,String fileName,String contentType,long sizeBytes,String uploadedAt) {}
/** 同一原意图只上传一次,未知结果由账本保存。 */
Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload);
/** 核验平台、原排车、分类、文件身份和图像格式。 */
Evidence metadata(long dispatchId,PhotoSlot slot,long fileId);
/** 只代理业务层已确认当前或历史引用的内容。 */
byte[] content(long dispatchId,PhotoSlot slot,long fileId);
}
@@ -1,94 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 本页4表唯一SQL入口;不更新排车、库存或财务表。 */
@Mapper
public interface OwnedBusinessMapper {
/** 读取已经冻结的装车归属,不查询当前车辆推断历史。 */
@Select("SELECT * FROM ll_fleet_owned_business_ownership WHERE dispatch_id=#{id}")
Ownership ownership(long id);
/** 在主装车事务保存唯一归属证据,重复交由提供者核对原事实。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_ownership(dispatch_id,vehicle_id,resource_mode,vehicle_ownership,
vehicle_record_version,ownership_captured_at,loading_completion_hash,loading_completed_at)
VALUES(#{dispatchId},#{vehicleId},#{resourceMode},#{vehicleOwnership},#{vehicleRecordVersion},
#{ownershipCapturedAt},#{loadingCompletionHash},#{loadingCompletedAt})
""")
int capture(Ownership row);
/** GET不使用此方法;首个写事务建立可锁定的附加0版本行。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_record(id,dispatch_id,record_version,arrivals_json,photos_json,
created_by,updated_by,created_at,updated_at) VALUES(#{id},#{dispatchId},0,'[]','[]',
#{createdBy},#{updatedBy},#{createdAt},#{updatedAt}) ON DUPLICATE KEY UPDATE dispatch_id=dispatch_id
""")
int ensure(Record row);
/** 只锁本页附加行,不反向申请车辆或派送写锁。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id} FOR UPDATE")
Record lockRecord(long id);
/** 详情只读附加信息,无记录返回虚拟0版本。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id}")
Record record(long id);
/** 页内批量读取,避免逐车补充GET和SQL。 */
@Select("<script>SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Record> records(@Param("ids") List<Long> ids);
/** CAS仅修改运营附加字段,不占用主单recordVersion。 */
@Update("""
UPDATE ll_fleet_owned_business_record SET record_version=#{r.recordVersion},currency=#{r.currency},
total_amount=#{r.totalAmount},actual_distance_miles=#{r.actualDistanceMiles},
fuel_consumption_litres_per_100km=#{r.fuelConsumptionLitresPer100Km},arrivals_json=#{r.arrivalsJson},
photos_json=#{r.photosJson},updated_by=#{r.updatedBy},updated_at=#{r.updatedAt}
WHERE dispatch_id=#{r.dispatchId} AND record_version=#{expected}
""")
int update(@Param("r") Record row,@Param("expected") long expected);
/** 命令键全车唯一,操作者在服务端另行核验。 */
@Select("SELECT * FROM ll_fleet_owned_business_command WHERE dispatch_id=#{id} AND idempotency_key=#{key}")
Command command(@Param("id") long id,@Param("key") String key);
/** 完整意图在文件HTTP之前单独事务提交。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_command(id,dispatch_id,operator_id,idempotency_key,action,
expected_version,request_hash,request_json,status,result_json,error_code,created_at,updated_at)
VALUES(#{id},#{dispatchId},#{operatorId},#{idempotencyKey},#{action},#{expectedVersion},
#{requestHash},#{requestJson},#{status},#{resultJson},#{errorCode},#{createdAt},#{updatedAt})
""")
int insertCommand(Command row);
/** 上传只完成原待定意图,不能把未知结果当新命令重做。 */
@Update("""
UPDATE ll_fleet_owned_business_command SET status=#{status},result_json=#{resultJson},
error_code=#{errorCode},updated_at=#{updatedAt} WHERE id=#{id} AND status='PENDING'
""")
int finishUpload(Command row);
/** 附加写与不可变历史同事务,版本不能重复。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_history(id,dispatch_id,record_version,action,operator_id,
reason,snapshot_json,occurred_at) VALUES(#{id},#{dispatchId},#{recordVersion},#{action},#{operatorId},
#{reason},#{snapshotJson},#{occurredAt})
""")
int insertHistory(History row);
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
@Select("""
SELECT * FROM (
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
UNION ALL
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
""")
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
/** 历史总数不取当前页长度。 */
@Select("SELECT (SELECT COUNT(*) FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id})+(SELECT COUNT(*) FROM ll_dispatch_history WHERE dispatch_id=#{id})")
int historyCount(long id);
/** 历史文件只能从指定真实历史快照读取。 */
@Select("SELECT * FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} AND record_version=#{version}")
History revision(@Param("id") long id,@Param("version") long version);
}
@@ -1,61 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
@Mapper
public interface OwnedBusinessQueryMapper {
String SOURCE="""
SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version,
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
""";
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
String FILTER="""
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
AND o.vehicle_ownership=#{q.scope.ownership}
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
AND o.loading_completed_at=d.loading_completed_at
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
<if test='q.to != null'> AND d.planned_departure_at &lt; #{q.to}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.keyword != null'> AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%')
OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i
WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%'))))</if>
""";
/** 只返回当前完整条件下的一车一行。 */
@Select("<script>"+SOURCE+" WHERE "+FILTER+" ORDER BY d.planned_departure_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<Source> page(@Param("q") Search query);
/** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */
@Select("<script>SELECT d.stage,COUNT(*) AS quantity FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id WHERE "+FILTER+" GROUP BY d.stage</script>")
List<StageCount> summary(@Param("q") Search query);
/** 先读原主单和可空归属,以明确区别无快照与不存在。 */
@Select(SOURCE+" WHERE d.dispatch_no=#{number}")
Source find(String number);
/** 页内全部货物一次读取,各客户保持原归属。 */
@Select("<script>SELECT id,dispatch_order_id,warehouse_pallet_id,customer_id,customer_name,source_key,order_no,inbound_code,pallet_no,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,id</script>")
List<CargoRow> cargo(@Param("ids") List<Long> ids);
/** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */
@Select("""
<script>SELECT a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,
p.appointment_start_utc AS start_utc,SUM(a.package_count) AS allocated_packages
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_item i
ON i.id=a.dispatch_item_id AND i.dispatch_order_id=a.dispatch_id AND i.customer_id=a.customer_id
JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
WHERE a.dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
GROUP BY a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,p.appointment_start_utc
ORDER BY a.dispatch_id,a.destination_key,p.appointment_no</script>
""")
List<Batch> batches(@Param("ids") List<Long> ids);
}
@@ -1,64 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Set;
/** 原排车只读投影和附加信息,不公开第二套履约命令。 */
public final class OwnedBusinessModels {
/** 工具容器不允许实例化。 */
private OwnedBusinessModels() {}
public static final String LIST="platform:fleet:owned-business:list";
public static final String EDIT="platform:fleet:owned-business:edit";
public static final String UPLOAD="platform:fleet:owned-business:upload";
public enum Stage { AFTER_LOADING, DELIVERING, DELIVERED }
public enum PhotoSlot {
LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT, FUEL_START, FUEL_END;
/** 图片框分类不表示司机审核或真实派送签收。 */
public String category() { return name().startsWith("FUEL_")?"FUEL":"LOADING"; }
/** 文件类型与租户归属由本域固定,浏览器不能指定。 */
public String businessType() { return category().equals("FUEL")?"OVERSEAS_DISPATCH_FUEL_PHOTO":"OVERSEAS_DISPATCH_LOADING_PHOTO"; }
}
public record Actor(String operatorId,String operatorName,Set<String> permissions) {}
public record Query(@Min(1) Integer page,@Min(1) @Max(200) Integer size,
String plannedDepartureFrom,String plannedDepartureTo,@Size(max=128) String keyword,
Stage stage,@Size(max=64) String warehouseId) {}
public record Money(@NotBlank @Pattern(regexp="CNY|USD") String currency,
@NotBlank @Pattern(regexp="(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?") String amount) {}
public record Arrival(@NotBlank @Size(max=256) String destinationKey,String actualArrivedAt) {}
public record Operations(@Valid Money money,String actualDistanceMiles,
String fuelConsumptionLitresPer100Km,@NotNull List<@NotNull @Valid Arrival> arrivals) {}
public record SaveOperations(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
@Size(max=1000) String reason) {}
public record BindPhoto(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
public record Gate(boolean allowed,String blockReason) {}
public record Actions(Gate edit,Gate upload) {}
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
public record Vehicle(String vehicleId,String plate,String ownership) {}
public record Driver(String name,String phone) {}
public record Appointment(String appointmentNo,String confirmationNo,String scheduledAt,String allocatedPackages) {}
public record Destination(String destinationKey,String warehouseCode,String warehouseName,String address,
List<Appointment> appointments,String actualArrivedAt) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,
String sizeBytes,String uploadedAt,String savedAt) {}
public record PhotoSummary(int loadingCount,int fuelCount) {}
public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt,
String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver,
List<Destination> destinations,Integer actualPalletCount,Operations operations,String unitPrice,
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
String inboundCode,String palletNo,String destinationKey,String packages) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {}
public record HistoryPage(List<History> records,int page,int size,int total) {}
public record Command(String dispatchNo,String action,String idempotencyKey,String state,
String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
public record Snapshot(Operations operations,List<Photo> photos,String operatorName) {}
}
@@ -1,57 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 仅供本包持久化,不向浏览器输出内部主键。 */
public final class OwnedBusinessRows {
/** 持久化容器不允许实例化。 */
private OwnedBusinessRows() {}
@Data public static class Ownership {
private Long dispatchId,vehicleId,vehicleRecordVersion;
private String resourceMode,vehicleOwnership,loadingCompletionHash;
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
}
@Data public static class Source {
private Long id,recordVersion;
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
private LocalDateTime plannedDepartureAt,loadingCompletedAt,departedAt,deliveryCompletedAt;
private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash;
private Long ownedVehicleId,ownedVehicleRecordVersion;
private LocalDateTime ownedLoadingCompletedAt;
}
/** 原表货物只读字段;不是可写排车实体,不复制来源状态机。 */
@Data public static class CargoRow {
private Long id,dispatchOrderId,warehousePalletId;
private String customerId,customerName,sourceKey,orderNo,inboundCode,palletNo;
private String destinationType,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,deliveryAddress,postalCode;
private Integer packageCount;
}
@Data public static class Record {
private Long id,dispatchId,recordVersion;
private String currency,arrivalsJson,photosJson,createdBy,updatedBy;
private BigDecimal totalAmount,actualDistanceMiles,fuelConsumptionLitresPer100Km;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class Command {
private Long id,dispatchId,expectedVersion;
private String operatorId,idempotencyKey,action,requestHash,requestJson,status,resultJson,errorCode;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class History {
private Long id,dispatchId,recordVersion,dispatchRecordVersion;
private String eventSource,action,operatorId,reason,snapshotJson;
private LocalDateTime occurredAt;
}
@Data public static class Batch {
private Long dispatchId;
private String destinationKey,appointmentNo,confirmationNo;
private LocalDateTime startUtc;
private Long allocatedPackages;
}
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
@Data public static class StageCount { private String stage;private Long quantity; }
}
@@ -1,26 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
public enum OwnedBusinessScope {
OWNED("OWNED","platform:fleet:owned-business:"),
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
private final String ownership,prefix;
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
public String getOwnership(){return ownership;}
/** 查看权限包含本作用域详情、历史和照片读取。 */
public String listPermission(){return prefix+"list";}
/** 运营写权限独立,外配权限不映射成自有权限。 */
public String editPermission(){return prefix+"edit";}
/** 照片写权限独立,不扩大到原排车。 */
public String uploadPermission(){return prefix+"upload";}
/** 外配只支持装车五框,服务调用同样不能绕过。 */
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
/** 不存在只显示当前业务归属,不泄露内部身份。 */
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
}
@@ -1,10 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.port;
import java.time.Instant;
/** 装车主事务唯一捕获车辆归属,不创建第二套履约状态。 */
public interface OwnedBusinessOwnershipCapture {
/** 主流程已稳定车辆资格,仅捕获原装车时点的不可变归属。 */
void capture(long dispatchId, Long vehicleId, String resourceMode,
String loadingCompletionHash, Instant loadingCompletedAt);
}
@@ -1,53 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.repository;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 只封装本页Mapper,关系库访问不使用JdbcTemplate或直连JDBC。 */
@Repository
public class OwnedBusinessRepository {
private final OwnedBusinessMapper records;
private final OwnedBusinessQueryMapper queries;
/** 注入唯一SQL提供者,Service不能直接访问Mapper。 */
public OwnedBusinessRepository(OwnedBusinessMapper records,OwnedBusinessQueryMapper queries){this.records=records;this.queries=queries;}
/** 原归属已捕获后不按当前车辆回算。 */
public Ownership ownership(long id){return records.ownership(id);}
/** 归属与主装车事务共同提交。 */
public void capture(Ownership row){records.capture(row);}
/** 服务端整车分页。 */
public List<Source> page(Search query){return queries.page(query);}
/** 与分页相同过滤的完整统计。 */
public List<StageCount> summary(Search query){return queries.summary(query);}
/** 读取唯一原排车身份及归属证据。 */
public Source source(String number){return queries.find(number);}
/** 页内货物批量查询。 */
public List<CargoRow> cargo(List<Long> ids){return ids.isEmpty()?List.of():queries.cargo(ids);}
/** 页内实际预约分配批量查询。 */
public List<Batch> batches(List<Long> ids){return ids.isEmpty()?List.of():queries.batches(ids);}
/** GET只读,无记录保留虚拟0版本。 */
public Record record(long id){return records.record(id);}
/** 页内附加信息批量读取。 */
public List<Record> records(List<Long> ids){return ids.isEmpty()?List.of():records.records(ids);}
/** 首个写事务建立行,并按dispatch唯一键串行。 */
public Record lock(Record seed){records.ensure(seed);return records.lockRecord(seed.getDispatchId());}
/** 只CAS修改附加信息。 */
public int update(Record row,long expected){return records.update(row,expected);}
/** 命令先按全车请求键定位,再在服务端检查原操作者。 */
public Command command(long id,String key){return records.command(id,key);}
/** 命令与附加版本同事务保存,或作为独立上传意图持久化。 */
public void command(Command row){records.insertCommand(row);}
/** 上传结果只完成原待定意图。 */
public int finishUpload(Command row){return records.finishUpload(row);}
/** 同一附加版本只记录一次不可变历史。 */
public void history(History row){records.insertHistory(row);}
/** 历史总数只取原dispatch。 */
public int historyCount(long id){return records.historyCount(id);}
/** 历史采用稳定版本倒序。 */
public List<History> history(long id,int offset,int size){return records.history(id,offset,size);}
/** 下载历史附件时精确定位原版本。 */
public History revision(long id,long version){return records.revision(id,version);}
}
@@ -1,62 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Command;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
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.time.Instant;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 附加操作与文件准备共用二元唯一账本,不允许异账号复用键。 */
@Service
public class OwnedBusinessCommandService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessSupport codec;
/** 文件意图独立短事务,不持业务行锁跨HTTP。 */
public OwnedBusinessCommandService(OwnedBusinessRepository repository,OwnedBusinessSupport codec){this.repository=repository;this.codec=codec;}
public record Reservation(Command command,boolean created) {}
/** 查找原命令时先核操作者,不披露别人的原载荷。 */
public Command existing(long dispatchId,String key,Actor actor) {
var row=repository.command(dispatchId,key);
if(row!=null && !Objects.equals(row.getOperatorId(),actor.operatorId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"该请求键不属于当前操作账号");
return row;
}
/** 原action、版本和完整规范请求必须一致,不能用当前版本重造旧意图。 */
public void same(Command row,String action,Long version,Object request) {
if(!Objects.equals(row.getAction(),action) || !Objects.equals(row.getExpectedVersion(),version)
|| !Objects.equals(row.getRequestHash(),codec.hash(java.util.Arrays.asList(row.getOperatorId(),action,version,request))))throw conflict("相同请求键的操作、版本或内容不同");
}
/** 完整命令实体只用于本页,不保存浏览器凭据。 */
public Command create(long id,Actor actor,String key,String action,Long version,Object request,String status) {
var now=codec.utc(Instant.now());var row=new Command();row.setId(IdWorker.getId());row.setDispatchId(id);
row.setOperatorId(actor.operatorId());row.setIdempotencyKey(key);row.setAction(action);row.setExpectedVersion(version);
row.setRequestHash(codec.hash(java.util.Arrays.asList(actor.operatorId(),action,version,request)));row.setRequestJson(codec.write(request));row.setStatus(status);row.setCreatedAt(now);row.setUpdatedAt(now);return row;
}
/** 首次上传先持久化意图,竞争者只能读取原状态,不再次调用File。 */
@Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
public Reservation reserveUpload(long id,Actor actor,String key,Object request) {
var previous=existing(id,key,actor);
if(previous!=null){same(previous,"UPLOAD_PHOTO",null,request);return new Reservation(previous,false);}
var row=create(id,actor,key,"UPLOAD_PHOTO",null,request,"PENDING");
try{repository.command(row);return new Reservation(row,true);}
catch(DuplicateKeyException race){var original=existing(id,key,actor);if(original==null)throw race;same(original,"UPLOAD_PHOTO",null,request);return new Reservation(original,false);}
}
/** 已知上传回执或未知结果只完成原意图,不创建新业务记录。 */
@Transactional(propagation=Propagation.REQUIRES_NEW)
public void finishUpload(Command command,String status,Object result,String errorCode) {
command.setStatus(status);command.setResultJson(result==null?null:codec.write(result));command.setErrorCode(errorCode);
command.setUpdatedAt(codec.utc(Instant.now()));
if(repository.finishUpload(command)!=1)throw conflict("上传命令结果已被其他操作确认,请核对原请求");
}
/** 写命令与附加更新、历史在同一外部事务提交。 */
public void success(long id,Actor actor,String key,String action,long version,Object request,Object result) {
var command=create(id,actor,key,action,version,request,"SUCCEEDED");command.setResultJson(codec.write(result));repository.command(command);
}
}
@@ -1,52 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 装车时车辆归属的唯一捕获实现,GET绝不补造历史。 */
@Service
public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapture {
private final OwnedBusinessRepository repository;
private final VehicleRepository vehicles;
/** 使用真实车辆提供者,不对车辆主档增加反向写锁。 */
public OwnedBusinessOwnershipService(OwnedBusinessRepository repository,VehicleRepository vehicles){this.repository=repository;this.vehicles=vehicles;}
/** 主流程原父事务已经稳定资源,捕获失败必须与装车一起回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void capture(long dispatchId,Long vehicleId,String resourceMode,String loadingCompletionHash,Instant loadingCompletedAt) {
if(dispatchId<=0 || loadingCompletionHash==null || loadingCompletionHash.isBlank() || loadingCompletionHash.length()>128 || loadingCompletedAt==null
|| !loadingCompletedAt.equals(loadingCompletedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)))throw bad("装车归属证据不完整");
if(!"MASTER".equals(resourceMode) && !"EXTERNAL".equals(resourceMode))throw bad("车辆资源模式不合法");
if("MASTER".equals(resourceMode)?vehicleId==null || vehicleId<=0:vehicleId!=null)throw bad("车辆资源身份不一致");
var existing=repository.ownership(dispatchId);
if(existing!=null){same(existing,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);return;}
var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode);
if("MASTER".equals(resourceMode)) {
var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在"));
if(vehicle.id()!=vehicleId.longValue())throw conflict("车辆归属提供者返回的身份不一致");
if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效");
if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确");
row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion());
} else {row.setVehicleOwnership("DISPATCHED");row.setVehicleRecordVersion(null);}
row.setLoadingCompletedAt(LocalDateTime.ofInstant(loadingCompletedAt,ZoneOffset.UTC));
row.setOwnershipCapturedAt(row.getLoadingCompletedAt());row.setLoadingCompletionHash(loadingCompletionHash);
try{repository.capture(row);}catch(DuplicateKeyException duplicate){
var raced=repository.ownership(dispatchId);if(raced==null)throw duplicate;
same(raced,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);
if(!Objects.equals(raced.getVehicleOwnership(),row.getVehicleOwnership()) || !Objects.equals(raced.getVehicleRecordVersion(),row.getVehicleRecordVersion()))throw conflict("装车车辆归属并发变化");
}
}
/** 只允许同一原装车事实重放,不能以新归属重写旧证据。 */
private void same(Ownership row,Long vehicleId,String mode,String hash,Instant at) {
if(!Objects.equals(row.getVehicleId(),vehicleId) || !Objects.equals(row.getResourceMode(),mode)
|| !Objects.equals(row.getLoadingCompletionHash(),hash) || !Objects.equals(row.getLoadingCompletedAt(),LocalDateTime.ofInstant(at,ZoneOffset.UTC)))
throw conflict("原装车车辆归属证据不同");
}
}
@@ -1,116 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 只有绑定成功才保存照片,上传和绑定各自使用稳定键。 */
@Service
public class OwnedBusinessPhotoService {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessRepository repository;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final OwnedBusinessFileGateway files;
/** HTTP远程请求不在附加行写事务内持锁等待。 */
public OwnedBusinessPhotoService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,
OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
this.queries=queries;this.records=records;this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
}
public record UploadIdentity(PhotoSlot slot,String fileName,String contentType,long sizeBytes,String sha256) {}
public record Download(Photo photo,byte[] content) {}
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED);
}
/** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key);
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
if(slot==null || multipart==null)throw bad("请选择照片及分类");
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
if(!reservation.created()) {
if(!"SUCCEEDED".equals(reservation.command().getStatus()))throw conflict("原上传结果尚未明确,请查询原请求,勿换键重传");
return codec.read(reservation.command().getResultJson(),Photo.class);
}
try {
var result=photo(slot,files.upload(source.getId(),slot,actor.operatorId(),payload));
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
} catch(RuntimeException failure) {
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");}
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
}
}
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
return bind(actor,number,slot,request,OwnedBusinessScope.OWNED);
}
/** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot);
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
if(fileId<=0)throw bad("文件编号无效");
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null) {
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
return codec.read(previous.getResultJson(),Detail.class);
}
var verified=photo(slot,files.metadata(source.getId(),slot,fileId));
return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope);
}
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED);
}
/** 五框历史授权仍由同车同版本的真实引用限定。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List<Photo> bound;
if(revision==null)bound=projection.photos(repository.record(source.getId()));
else {
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
}
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing);
return new Download(photo,files.content(source.getId(),slot,fileId));
}
/** 恢复先校验原操作者,不披露他人载荷。 */
@Transactional(readOnly=true)
public Command command(Actor actor,String number,String key) {
return command(actor,number,key,OwnedBusinessScope.OWNED);
}
/** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */
@Transactional(readOnly=true)
public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor);
if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
}
/** 上传回执不填写保存时间,避免假称已经关联成功。 */
private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence evidence) {
return new Photo(slot,slot.category(),String.valueOf(evidence.fileId()),evidence.fileName(),evidence.contentType(),
String.valueOf(evidence.sizeBytes()),evidence.uploadedAt(),null);
}
}
@@ -1,91 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Component;
import java.math.*;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 组合原排车与附加信息;不复制、推进或重新归并订单。 */
@Component
public class OwnedBusinessProjection {
private final OwnedBusinessSupport codec;
/** 所有数值与日期转换沿统一显式规则。 */
public OwnedBusinessProjection(OwnedBusinessSupport codec){this.codec=codec;}
/** 未登记为null,不以默认油耗、金额或到达填充。 */
public Operations operations(Record record) {
if(record==null)return new Operations(null,null,null,List.of());
var money=record.getTotalAmount()==null?null:new Money(record.getCurrency(),record.getTotalAmount().setScale(2).toPlainString());
return new Operations(money,decimal(record.getActualDistanceMiles()),decimal(record.getFuelConsumptionLitresPer100Km()),
codec.list(record.getArrivalsJson(),Arrival.class));
}
/** 照片已绑定才出现在当前记录,无文件不生成占位图。 */
public List<Photo> photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);}
/** 使用原货物确定目的地key,保持与排车及到达登记一致。 */
public String destinationKey(CargoRow item) {
String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),
item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key;
}
/** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */
public int palletCount(Source source) {
var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class);
if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失");
var ids=new HashSet<Long>();
for(var pallet:confirmed.pallets()) {
if(pallet==null || pallet.warehousePalletId()==null || pallet.warehousePalletId()<=0)throw OwnedBusinessSupport.conflict("实际装车托盘身份缺失");
ids.add(pallet.warehousePalletId());
}
return ids.size();
}
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED);
}
/** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
var destinations=new LinkedHashMap<String,Destination>();
for(var item:cargo) {
String key=destinationKey(item);
destinations.computeIfAbsent(key,k->new Destination(k,item.getDestinationWarehouseCode()==null || item.getDestinationWarehouseCode().isBlank()?item.getFbaWarehouseCode():item.getDestinationWarehouseCode(),item.getDestinationWarehouseName(),
item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment(
b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k)));
}
String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString();
var driver=resources.driver();
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
}
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
public String carrierName(Detail detail) {
return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null
?null:detail.dispatch().vehicleInfo().vehicle().carrierName();
}
/** 附加权限独立于原排车写动作。 */
private Gate gate(Actor actor,String permission) {
boolean allowed=actor!=null && actor.permissions()!=null && actor.permissions().contains(permission);
return new Gate(allowed,allowed?null:"没有该业务操作权限");
}
/** 数据库十进制保持显式单位,不以空值替换0。 */
private String decimal(BigDecimal value){return value==null?null:value.toPlainString();}
}
@@ -1,128 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
@Service
public class OwnedBusinessQueryService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final DispatchDetailReader dispatch;
/** 主单详情沿既有真实提供者,列表使用本域批量投影。 */
public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,DispatchDetailReader dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;}
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ListResult list(Actor actor,Query query) {
return listScoped(actor,query,OwnedBusinessScope.OWNED).result();
}
/** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */
public record ScopedListResult(ListResult result,Map<String,String> carrierNames) {}
/** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失");
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope);
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
for(var c:counts) {
int n=Math.toIntExact(c.getQuantity());
switch(c.getStage()){case "AFTER_LOADING"->after=n;case "DELIVERING"->delivering=n;case "DELIVERED"->delivered=n;default->throw conflict("阶段统计事实不合法");}
}
int total=Math.addExact(Math.addExact(after,delivering),delivered);
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()),
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList();
var carriers=new LinkedHashMap<String,String>();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source));
return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers));
}
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
public Detail detailForWrite(Actor actor,Source source) {
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
}
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
projection.photos(repository.record(source.getId())));
}
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
public Source requireOwned(String number) {
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
}
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
public Source requireScoped(String number,OwnedBusinessScope scope) {
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source;
}
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source,OwnedBusinessScope scope) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
}
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED);
}
/** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize);
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
return new History(h.getEventSource(),h.getRecordVersion()==null?null:h.getRecordVersion().toString(),
h.getDispatchRecordVersion()==null?null:h.getDispatchRecordVersion().toString(),h.getAction(),snapshot==null?null:snapshot.operatorName(),codec.iso(h.getOccurredAt()),h.getReason(),
snapshot==null?null:snapshot.operations(),snapshot==null?List.of():snapshot.photos());
}).toList(),page,size,repository.historyCount(source.getId()));
}
/** 页码初始1,缺失与非法值不能混同。 */
private int page(Integer value){if(value==null)return 1;if(value<1)throw bad("页码须大于0");return value;}
/** 列表和历史均限制单页读取量。 */
private int size(Integer value){if(value==null)return 20;if(value<1 || value>200)throw bad("每页条数须为1至200");return value;}
/** 空白可选条件归一化,无限长输入明确拒绝。 */
private String optional(String value,int max){if(value==null || value.isBlank())return null;if(value.length()>max)throw bad("筛选条件过长");return value.trim();}
}
@@ -1,136 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 运营信息和分类照片只写本页附加版本,绝不改主单版本或发布费用。 */
@Service
public class OwnedBusinessRecordService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessProjection projection;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessSupport codec;
/** 业务规则经Repository读写,命令与历史一起提交。 */
public OwnedBusinessRecordService(OwnedBusinessRepository repository,OwnedBusinessQueryService queries,
OwnedBusinessProjection projection,OwnedBusinessCommandService commands,OwnedBusinessSupport codec) {
this.repository=repository;this.queries=queries;this.projection=projection;this.commands=commands;this.codec=codec;
}
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,SaveOperations request) {
return save(actor,number,request,OwnedBusinessScope.OWNED);
}
/** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) {
codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null))
throw bad("外配业务不支持里程或油耗登记");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
var ops=normalize(source,request.operations());var row=lock(source,actor);
previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
requireVersion(row,version);
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
row.setArrivalsJson(codec.write(ops.arrivals()));
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope);
}
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED);
}
/** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot);
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
Object identity=new PhotoBinding(slot,request);
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
if(slot!=verified.slot() || !Objects.equals(slot.category(),verified.category()) || !Objects.equals(request.fileId(),verified.fileId()))throw bad("照片不属于本框");
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
requireVersion(row,version);
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
}
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) {
return switch(slot) {
case LOADING_SCENE -> "保存装车现场照片";
case LOADING_LIST -> "保存装车清单照片";
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
case DRIVER_LICENSE -> "保存驾驶证附件照片";
case RECEIPT -> "保存签收凭证照片";
case FUEL_START -> "保存起始油耗表盘照片";
case FUEL_END -> "保存结束油耗表盘照片";
};
}
/** 照片框也参与摘要,不能换URL框位复用原键。 */
public record PhotoBinding(PhotoSlot slot,BindPhoto request) {}
/** 先校验所有目的地及数值,再进入附加行写事务。 */
private Operations normalize(Source source,Operations ops) {
Money money=null;
if(ops.money()!=null) {
if(!Set.of("CNY","USD").contains(ops.money().currency()==null?"":ops.money().currency()))throw bad("币种须为CNY或USD");
String amount=codec.decimal(ops.money().amount(),16,2);if(amount==null)throw bad("金额与币种必须同时登记");
money=new Money(ops.money().currency(),amount);
}
if(ops.arrivals()==null)throw bad("目的地到达登记不完整");
var destinations=new HashSet<String>();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item));
var seen=new HashSet<String>();var arrivals=new ArrayList<Arrival>();
for(var arrival:ops.arrivals()) {
if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车");
var at=codec.instant(arrival.actualArrivedAt());arrivals.add(new Arrival(arrival.destinationKey(),at==null?null:at.toString()));
}
arrivals.sort(Comparator.comparing(Arrival::destinationKey));
return new Operations(money,codec.decimal(ops.actualDistanceMiles(),9,3),
codec.decimal(ops.fuelConsumptionLitresPer100Km(),9,3),List.copyOf(arrivals));
}
/** 首次写建立0版本附加行,仅在成功事务中最终提交。 */
private Record lock(Source source,Actor actor) {
var seed=new Record();seed.setId(IdWorker.getId());seed.setDispatchId(source.getId());seed.setCreatedBy(actor.operatorId());
seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed);if(row==null)throw conflict("附加记录初始化失败");return row;
}
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) {
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope);
commands.success(source.getId(),actor,key,action,expected,request,result);return result;
}
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
private Detail result(OwnedBusinessRows.Command command) {
if(!"SUCCEEDED".equals(command.getStatus()))throw conflict("原命令结果尚未明确,请按原请求恢复");
return codec.read(command.getResultJson(),Detail.class);
}
/** null是未登记,不以0代替。 */
private BigDecimal decimal(String value){return value==null?null:new BigDecimal(value);}
}
@@ -1,94 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JavaType;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** 只处理本页格式、身份和JSON,不读写主流程。 */
@Component
public class OwnedBusinessSupport {
private final ObjectMapper json;
/** 复用服务统一Jackson,不建立第二套日期或版本协议。 */
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
public Actor actor(AuthenticatedUser user) {
return actor(user,OwnedBusinessScope.OWNED);
}
/** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */
public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) {
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
String operator=user.userId()==null?user.username():user.userId().toString();
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
return new Actor(operator,name,user.permissions()==null?Set.of():Set.copyOf(user.permissions()));
}
/** 写权限独立校验,直接Service调用也不能绕过。 */
public void require(Actor actor,String permission) {
if(actor==null || actor.operatorId()==null || actor.permissions()==null || !actor.permissions().contains(permission))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该业务操作权限");
}
/** JSON只从本页记录和已确认排车事实读取,损坏不静默恢复空值。 */
public <T>T read(String value,Class<T> type) {
if(value==null || value.isBlank())throw conflict("缺少可靠业务事实");
try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");}
}
/** 集合记录与声明的元素类型共同校验。 */
public <T>List<T> list(String value,Class<T> type) {
JavaType listType=json.getTypeFactory().constructCollectionType(List.class,type);
try{return json.readValue(value,listType);}catch(Exception error){throw conflict("业务集合事实读取失败");}
}
/** 原请求和原结果完整持久化,序列化失败必须中止命令。 */
public String write(Object value) {
try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("业务记录序列化失败",error);}
}
/** 请求摘要不包含版本漂移后的当前数据。 */
public String hash(Object value){return digest(write(value).getBytes(StandardCharsets.UTF_8));}
/** 文件内容摘要用于识别相同上传原请求,不保存图片进数据库。 */
public String digest(byte[] bytes) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
catch(Exception error){throw new IllegalStateException("无法计算业务摘要",error);}
}
/** 版本0合法,但缺失、负数和超长版本不得降级为0。 */
public long version(String value) {
try{if(value==null || !value.matches("0|[1-9][0-9]*"))throw new IllegalArgumentException();return Long.parseLong(value);}
catch(Exception error){throw bad("请提交有效的原记录版本");}
}
/** 请求键在页面恢复期间保持稳定,不能用空键创建命令。 */
public void key(String key){if(key==null || key.length()<8 || key.length()>128)throw bad("请求键长度须为8至128");}
/** 只接受带时区的真实时间,统一存UTC。 */
public Instant instant(String value) {
try{return value==null?null:OffsetDateTime.parse(value).toInstant();}
catch(Exception error){throw bad("时间须包含明确时区");}
}
/** 关系库时间只表示UTC,不取运行机默认时区。 */
public LocalDateTime utc(Instant value){return value==null?null:LocalDateTime.ofInstant(value,ZoneOffset.UTC);}
/** 展示原真实时间,不根据签收或预计时间填充。 */
public String iso(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
/** 规范十进制输入,空值与明确零分别保存。 */
public String decimal(String value,int integers,int scale) {
if(value==null)return null;
if(!value.matches("(0|[1-9][0-9]{0,"+(integers-1)+"})(\\.[0-9]{1,"+scale+"})?"))throw bad("数量或金额须为非负规范十进制");
return new BigDecimal(value).setScale(scale).toPlainString();
}
/** 用户输入错误保持项目业务错误约定。 */
public static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 业务并发或来源事实不符不能返回模拟成功。 */
public static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
/** 不存在只使用业务提示,不泄露内部主键。 */
public static BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前自有业务");}
}
@@ -36,16 +36,7 @@ public final class FleetCapacityOfferModels {
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
@NotBlank @Size(min=8,max=128) String idempotencyKey,
@Size(max=64) String sourceOrderNo,@Min(0) Long sourceOrderVersion) {
/** 保留无原单人工登记的构造方式。 */
public InquiryWrite(Long tripRecordVersion,Long offerRecordVersion,Long recordVersion,Long customerId,
String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
FleetBookingModels.Cargo cargo,FleetQuoteCandidateModels.QuoteRef quoteRef,String idempotencyKey) {
this(tripRecordVersion,offerRecordVersion,recordVersion,customerId,businessType,deliveryMode,
estimatedArrivalAt,expectedPalletCount,cargo,quoteRef,idempotencyKey,null,null);
}
}
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@Size(max=1000) String reason) { }
@@ -25,7 +25,6 @@ import java.util.*;
@Service
public class FleetCapacityOfferService {
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands;
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
private final FleetCapacityOfferMapper offers;
private final FleetCapacityOfferCommandMapper commands;
@@ -51,11 +50,6 @@ public class FleetCapacityOfferService {
this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService;
this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock;
}
/** 平台需求池分页读取客户已下的散板原单。 */
@Transactional(readOnly=true)
public PageResult<com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.Demand> pendingDemands(FleetCapacityOfferModels.InquiryQuery query) {
writePermission("inquiry-create");return demands.pending(query.page(),query.size(),query.keyword());
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
readPermission();
@@ -177,20 +171,17 @@ public class FleetCapacityOfferService {
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
}
/** 同一事务登记意向及原单来源,确认前不占位。 */
@Transactional
public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true);
var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
var source=demands.prepare(request.customerId().toString(),request.businessType(),request.sourceOrderNo(),request.sourceOrderVersion());
var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef()));
if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对");
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
bookings.updateById(booking);demands.attach(booking.getId(),source);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
}
/** 编辑登记资料时保持客户及原单来源不可变。 */
@Transactional
public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());
@@ -198,7 +189,6 @@ public class FleetCapacityOfferService {
var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记");
demands.validateEdit(booking.getId(),booking.getCustomerId(),request.businessType(),request.sourceOrderNo());
if(request.recordVersion()==null)throw bad("缺少意向版本");
bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef()));
booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking);
@@ -1,20 +0,0 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/** 运输执行仍由车队应用边界核对车辆主档,不向排车暴露车辆持久化。 */
@Service
public class FleetTransportVehicleGuard {
private final VehicleRepository vehicles;
/** 复用车辆应用端口及其行锁。 */
public FleetTransportVehicleGuard(VehicleRepository vehicles){this.vehicles=vehicles;}
/** 发车核对实时车辆资格,并冻结当次有效容量供业务量复核。 */
public void validate(FleetTripEntity trip){
var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"车辆不存在"));
if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw new ResponseStatusException(HttpStatus.CONFLICT,"当前车辆不可出车");
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces());trip.setMaxLoadKg(vehicle.maxLoadKg());trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
}
}
@@ -276,7 +276,7 @@ public class FleetTripExecutionService {
}
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
if (java.util.Set.of("PICKUP","LOCAL_TRUCK").contains(java.util.Objects.toString(trip.getTripPurpose(),""))) throw conflict("运输排车请使用对应运输任务操作");
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
}
@@ -27,7 +27,6 @@ import java.util.*;
@Service
public class FleetTripService {
@org.springframework.beans.factory.annotation.Autowired(required=false) private org.springframework.context.ApplicationEventPublisher transportEvents;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripRepository repository;
@@ -102,12 +101,6 @@ public class FleetTripService {
return createPlan(operator, request, "PICKUP");
}
/** 本土卡车运输使用同一资源占用与司机资格规则。 */
@Transactional
public FleetTripModels.Detail createLocalTruck(String operator, FleetTripModels.Create request) {
return createPlan(operator, request, "LOCAL_TRUCK");
}
/** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) {
@@ -383,7 +376,7 @@ public class FleetTripService {
/** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */
private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) {
String hash = hash(("DELIVERY".equals(purpose) ? "" : purpose+":") + write(request));
String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request));
var existing = repository.findCreated(operator, request.idempotencyKey());
if (existing != null) return replay(existing, hash);
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
@@ -454,7 +447,7 @@ public class FleetTripService {
bookingReservations.requireRoute(trip, request.stops());
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
boolean unchangedRoute = response(trip).stops().equals(request.stops());
if (Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")) && !unchangedRoute) throw conflict("运输路线由原单确定,不能修改来源地址");
if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线");
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
@@ -650,7 +643,6 @@ public class FleetTripService {
}
/** 为当前版本保存操作人、原因和完整车次历史。 */
private void record(FleetTripEntity trip, String action, String reason, String operator) {
if(transportEvents!=null)transportEvents.publishEvent(new TransportTripChanged(trip,action,reason,operator));
workChanges.changed(trip);
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
@@ -29,9 +29,6 @@ public class PlatformFleetCapacityOfferController {
public ApiResponse<FleetCapacityOfferModels.OfferResult> resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));}
@PostMapping("/{offerNo}/close") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.OfferResult> close(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"CLOSE",request));}
/** 读取客户已提交、等待车次匹配的散板原单。 */
@GetMapping("/pending-looseboard-demands") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))")
public ApiResponse<PageResult<com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.Demand>> pendingDemands(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.pendingDemands(query));}
@GetMapping("/inquiries") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<PageResult<FleetCapacityOfferModels.Inquiry>> inquiries(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.inquiries(query));}
@GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
@@ -1,5 +0,0 @@
package com.baiyue.lastleg.fleet.trip;
/** 同步事务内的运输事实;任一投影失败时原执行一起回滚。 */
public record TransportTripChanged(FleetTripEntity trip,String action,String reason,String operator) { }
@@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper {
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
/** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */
@Select("""
<script>
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id))
AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked,
l.order_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
@@ -31,7 +31,7 @@ public interface AppointmentWorkDispatchMapper {
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE <foreach collection='items' item='i' open='(' separator=' OR ' close=')'>(p.id=#{i.planPalletId} AND p.customer_id=#{i.customerId})</foreach>
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,l.order_id,l.destination_type,l.fba_warehouse_code
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,l.order_id,l.destination_type,l.fba_warehouse_code
ORDER BY p.id,l.order_id,l.destination_type,l.fba_warehouse_code LIMIT 20001
</script>
""")
@@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper {
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
@Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List<DispatchPhysical> dispatchPhysicalById(long id);
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
List<Candidate> candidates(long id);
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
@@ -51,7 +51,7 @@ public class LocalLooseboardService {
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper;
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
@Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
@@ -75,7 +75,8 @@ public class LocalLooseboardService {
order.setVersion(0L);
order.setCreatedAt(now);
order.setUpdatedAt(now);
// 下单只保存池内需求,实际收货时再创建唯一实物尾程单。
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
order.setLastlegOrderId(lastlegOrder.getId());
try {
repository.insertOrder(order);
} catch (DuplicateKeyException exception) {
@@ -85,7 +86,8 @@ public class LocalLooseboardService {
}
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
key, null, request.remark(), now);
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
"Local looseboard order submitted", now);
return detail(customerId, order.getOrderNo());
}
@@ -156,7 +158,6 @@ public class LocalLooseboardService {
}
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status());
if (order.getLastlegOrderId() == null && !CANCELLED.equals(next)) throw conflict("请从散板需求池匹配车次,沿实际收货、排车和签收推进");
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
String from = order.getStatus();
if (next.equals(from)) return response(order);
@@ -68,9 +68,7 @@ public class RelabelDispatchSourceRepository {
if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
if (position == null) continue;
positions.put(position.getId(), position);
// 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
&& position.getPalletId() > 0
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
}
}
@@ -149,9 +149,8 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
long notified = number(item.getNotifiedPackageCount());
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
|| position.getPalletId() <= 0) {
reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
bound = false;
}
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
@@ -8,9 +8,6 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface UsTruckOrderMapper extends BaseMapper<UsTruckOrder> {
/** 原单被有效排车接管后,禁止独立改状态绕过运输事实。 */
@Select("SELECT COUNT(*) FROM ll_dispatch_truck_task WHERE customer_id=#{customer} AND order_no=#{number} AND active_order IS NOT NULL")
long activeDispatch(@Param("customer") String customer,@Param("number") String number);
@Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1")
UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
@@ -44,8 +44,6 @@ public class UsTruckOrderRepository {
public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); }
/** 在当前事务中按客户和订单号加行锁。 */
public UsTruckOrder lockNo(String customerId, String orderNo) { return orders.lockNo(customerId, orderNo); }
/** 有效运输任务持有原单的执行权。 */
public boolean hasActiveDispatch(String customer,String number) { return orders.activeDispatch(customer,number)>0; }
/** 按客户和创建幂等键读取。 */
public UsTruckOrder findKey(String customerId, String key) { return orders.findKey(customerId, key); }
/** 保存新订单。 */
@@ -396,7 +396,7 @@ public class UsTruckOrderService {
}
/** 按客户范围锁单并校验独立订单资格。 */
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); if(repository.hasActiveDispatch(customerId,orderNo))throw conflict("该单已接入排车,请在排车列表执行运输操作"); return o; }
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; }
/** 复核创建请求摘要并返回既有订单。 */
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
/** 读取订单资料与实际托盘和操作记录。 */
@@ -41,12 +41,6 @@ CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
@@ -1208,7 +1202,6 @@ CREATE TABLE ll_fleet_external_booking (
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
@@ -1223,7 +1216,6 @@ CREATE TABLE ll_fleet_external_booking (
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status),
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
@@ -1900,7 +1892,6 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
@@ -4562,108 +4553,3 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
PRIMARY KEY (dispatch_id),
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT ck_owned_business_resource_mode CHECK (
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
photos_json JSON 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_owned_business_record_dispatch (dispatch_id),
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL)
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
operator_id VARCHAR(64) 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_owned_business_history_version (dispatch_id,record_version),
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
-- 运输原单接入现有排车,不产生仓内库存事实。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
@@ -1,99 +0,0 @@
openapi: 3.0.3
info:
title: 现有排车运输任务
version: 1.0.0
description: >-
平台主体(tenantId=0)在现有排车列表内安排到仓取货、本土卡车,使用真实PC排车号。
请求经统一Gateway与Bearer认证;JSON响应HTTP200,业务code=200成功,A0400参数错误、A0403权限不足、A0404不存在、A0409版本或状态冲突。
list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。
创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。
到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。
servers:
- url: /api/platform/v1/last-leg/dispatches/transport-tasks
security:
- bearerAuth: []
paths:
/pickup-candidates:
get:
summary: 同仓上门取货候选(list,page/size/keyword/warehouseId)
responses: &response
'200':
description: 业务响应信封;data为对应详情、候选或操作结果
/truck-candidates:
get:
summary: 待派车且未被运输或拼车占用的本土卡车候选(list)
responses: *response
/pickup:
post:
summary: 同一海外仓多客户取货安排(create)
description: sources为customerId/transferNo数组,plannedDepartureAt和expectedDriverEndAt为ISO时刻;idempotencyKey必填,remark可选。返回Detail。
responses: *response
/truck:
post:
summary: 本土卡车原单接入排车(create)
description: customerId、orderNo、warehouseId、plannedDepartureAt、expectedDriverEndAt、idempotencyKey必填;原单须PENDING_DISPATCH且未被占用。返回Detail。
responses: *response
/{number}:
parameters: &number
- { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 }
get:
summary: 排车运输详情(list)
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)。
responses: *response
/{number}/{action}:
parameters:
- { name: number, in: path, required: true, schema: { type: string } }
- { name: action, in: path, required: true, schema: { type: string, enum: [assign, cancel, resource-occupancy, release] } }
post:
summary: 分配、取消、资源可用性查询与实际任务释放
description: >-
assign(edit):recordVersion、vehicleId、driverId、reason;cancel(cancel):recordVersion、reason。
resource-occupancy(list):kind=VEHICLE/DRIVER、resourceIds数组。
release(edit):recordVersion、resourceKind、actualEndAt、endLocation、vehicleStatus(车辆必填)、idempotencyKey、reason。
responses: *response
/{number}/{kind}/{action}:
parameters:
- { name: number, in: path, required: true, schema: { type: string } }
- { name: kind, in: path, required: true, schema: { type: string, enum: [pickup, truck] } }
- { name: action, in: path, required: true, schema: { type: string, enum: [depart, complete] } }
post:
summary: 运输发车(depart)或完成(edit)
description: recordVersion、idempotencyKey、reason必填。本土卡车complete还须至少一份evidenceFileIds,文件归属本任务。取货complete要求所有原单已送仓。
responses: *response
/{number}/pickup/{customer}/{source}/{action}:
parameters:
- { name: number, in: path, required: true, schema: { type: string } }
- { name: customer, in: path, required: true, schema: { type: string } }
- { name: source, in: path, required: true, schema: { type: string }, description: 仓储转运单号 }
- { name: action, in: path, required: true, schema: { type: string, enum: [picked, arrive, files] } }
post:
summary: 按原单上传凭证、确认实提或送仓(edit)
description: files使用multipart字段file;picked使用recordVersion、idempotencyKey、actualPackages、evidenceFileIds、remark;arrive使用recordVersion、idempotencyKey、reason。凭证不能跨客户原单使用。
responses: *response
/{number}/truck/files:
parameters: *number
post:
summary: 本土卡车凭证上传(edit)
description: multipart字段file,非空且不超过10MB,只能在计划或运输中上传。上传不等于完成,须通过complete命令确认文件引用。
responses: *response
/{number}/truck/files/{fileId}:
parameters:
- { name: number, in: path, required: true, schema: { type: string } }
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
get:
summary: 已确认本土卡车完成凭证鉴权下载(list)
responses:
'200': { description: application/octet-stream文件;失败返回业务响应信封 }
/{number}/pickup/{customer}/{source}/files/{fileId}:
parameters:
- { name: number, in: path, required: true, schema: { type: string } }
- { name: customer, in: path, required: true, schema: { type: string } }
- { name: source, in: path, required: true, schema: { type: string } }
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
get:
summary: 当前排车原单已确认取货凭证鉴权下载(list)
responses:
'200': { description: application/octet-stream文件;当前排车及原单归属不符时拒绝 }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -373,36 +373,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/OfferCommand'
/api/platform/v1/last-leg/fleet/capacity-offers/pending-looseboard-demands:
get:
summary: 分页读取客户已提交的待匹配散板原单
description: 权限:platform:fleet:capacity-offer:manage 或 (platform:fleet:capacity-offer:list 且 platform:fleet:capacity-offer:inquiry-create)。仅返回待接单、没有独立尾程来源且没有生效匹配的原单;取消未确认登记后恢复候选。下单及登记不占位,确认承运才占位。旧独立履约单不回填来源。
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 } }
- { name: keyword, in: query, schema: { type: string, maxLength: 128 } }
responses:
'200':
description: 统一业务响应
content:
application/json:
schema:
type: object
properties:
code: { type: string }
message: { type: string }
data:
type: object
properties:
records:
type: array
items: { $ref: '#/components/schemas/LooseboardDemand' }
page: { type: integer }
size: { type: integer }
total: { type: integer }
pages: { type: integer }
'401': { description: 未登录 }
'403': { description: 无需求匹配权限 }
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries:
get:
summary: 分页查询本人或获准的全部意向
@@ -1340,24 +1310,6 @@ components:
minimum: 0
- type: string
pattern: ^[0-9]+$
LooseboardDemand:
type: object
description: 原单待匹配投影;不含创建幂等键、请求哈希和操作人技术身份。
properties:
orderNo: { type: string }
customerId: { type: string }
version: { type: integer, format: int64 }
origin: { type: string }
destination: { type: string }
productName: { type: string, nullable: true }
estimatedPalletCount: { type: integer, nullable: true }
packageCount: { type: integer, nullable: true }
totalWeightKg: { type: number, nullable: true }
totalVolumeCbm: { type: number, nullable: true }
currency: { type: string }
originContactName: { type: string, nullable: true }
originContactPhone: { type: string, nullable: true }
remark: { type: string, nullable: true }
InquiryWrite:
type: object
required:
@@ -1369,18 +1321,6 @@ components:
- cargo
- idempotencyKey
properties:
sourceOrderNo:
type: string
maxLength: 64
nullable: true
description: 从待匹配散板选择原商业单;仅LOCAL_LOOSEBOARD支持。确认承运复用原单,不另开商业单。
sourceOrderVersion:
type: integer
format: int64
minimum: 0
nullable: true
description: 创建匹配时与原单号一起提交;已匹配登记不可替换来源。
tripRecordVersion:
oneOf:
- type: integer
@@ -1,304 +0,0 @@
openapi: "3.0.3"
info:
title: "原排车外配车辆运营视图"
version: "1.0.0"
description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/outsourced-business:
get:
operationId: listOutsourcedBusiness
summary: "外配列表及同筛选四项完整统计"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: "原排车号、车牌或真实目的仓/FBA编码" }
- { name: warehouseId, in: query, schema: { type: string, maxLength: 64 } }
- { name: plannedDepartureFrom, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间下界(含)" }
- { name: plannedDepartureTo, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间上界(不含)" }
- { name: stage, in: query, schema: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } }
responses:
"200":
description: "完整条件统计不带分页,不逐行GET;响应为统一ApiResponse。"
content:
application/json:
schema: { "$ref": "#/components/schemas/ListResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusiness
summary: "外配原排车组合详情"
x-permission-code: "platform:fleet:outsourced-business:list"
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/history:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusinessHistory
summary: "原排车与运营附加历史分页"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
responses:
"200":
description: "DISPATCH/OPERATIONS保留独立版本,原历史无虚构运营字段。"
content:
application/json:
schema: { "$ref": "#/components/schemas/HistoryResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/operations:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
put:
operationId: saveOutsourcedBusinessOperations
summary: "登记或清空整车承运金额和逐目的仓实际到达"
x-permission-code: "platform:fleet:outsourced-business:edit"
requestBody:
required: true
content:
application/json:
schema: { "$ref": "#/components/schemas/SaveOperations" }
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/uploads:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
post:
operationId: uploadOutsourcedBusinessPhoto
summary: "准备五框照片文件,尚未绑定"
x-permission-code: "platform:fleet:outsourced-business:upload"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file, idempotencyKey]
properties:
file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
responses:
"200":
description: "savedAt=null;未知结果保留原键查询,不重传。"
content:
application/json:
schema: { "$ref": "#/components/schemas/PhotoResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
put:
operationId: bindOutsourcedBusinessPhoto
summary: "以独立稳定键绑定原File编号"
x-permission-code: "platform:fleet:outsourced-business:upload"
requestBody:
required: true
content:
application/json:
schema: { "$ref": "#/components/schemas/BindPhoto" }
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/files/{fileId}/content:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
get:
operationId: readOutsourcedBusinessPhotoContent
summary: "读取当前或精确历史版本的同车照片引用"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { name: revisionVersion, in: query, schema: { type: integer, format: int64, minimum: 1 } }
responses:
"200":
description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。"
content:
image/png: { schema: { type: string, format: binary } }
image/jpeg: { schema: { type: string, format: binary } }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusinessCommand
summary: "仅原操作账号查询原命令结果"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: idempotencyKey, in: path, required: true, schema: { type: string, minLength: 8, maxLength: 128 } }
responses:
"200":
description: "同dispatch唯一账本;相同键异内容/版本冲突,异账号拒绝,未知不自动重试。"
content:
application/json:
schema: { "$ref": "#/components/schemas/CommandResponse" }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
DispatchNo: { name: dispatchNo, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
Slot: { name: slot, in: path, required: true, schema: { "$ref": "#/components/schemas/PhotoSlot" } }
responses:
DetailResponse:
description: "统一ApiResponse;成功code=200,业务拒绝A0400/A0403/A0404/A0409,认证/网关可真实401/403/503;保留requestId/traceId。"
content:
application/json:
schema:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Detail" } ], nullable: true } } }
schemas:
Envelope:
type: object
properties:
code: { type: string }
message: { type: string }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
Operations:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.Operations
required: [arrivals]
properties:
money: { allOf: [ { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Money" } ], nullable: true }
arrivals: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Arrival" }, description: "只接受原实际destinationKey,禁止重复或其他车辆目的地;省略目的地或null均不推定到达" }
SaveOperations:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.SaveOperations
required: [recordVersion, idempotencyKey, operations]
properties:
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
operations: { "$ref": "#/components/schemas/Operations" }
reason: { type: string, nullable: true, maxLength: 1000 }
BindPhoto:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.BindPhoto
required: [recordVersion, idempotencyKey, fileId]
properties:
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
fileId: { type: string, pattern: "^[1-9][0-9]*$" }
Vehicle:
type: object
x-java-record: OutsourcedBusinessModels.Vehicle
required: [ownership]
properties:
vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" }
plate: { type: string, nullable: true }
ownership: { type: string, enum: [DISPATCHED] }
carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" }
PhotoSlot:
type: string
enum: [LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT]
Photo:
type: object
x-java-record: OutsourcedBusinessModels.Photo
required: [slot, category, fileId, fileName, contentType, sizeBytes, uploadedAt]
properties:
slot: { "$ref": "#/components/schemas/PhotoSlot" }
category: { type: string, enum: [LOADING] }
fileId: { type: string }
fileName: { type: string }
contentType: { type: string, enum: [image/png, image/jpeg] }
sizeBytes: { type: string }
uploadedAt: { type: string, format: date-time }
savedAt: { type: string, format: date-time, nullable: true }
PhotoSummary:
type: object
x-java-record: OutsourcedBusinessModels.PhotoSummary
required: [loadingCount]
properties:
loadingCount: { type: integer, minimum: 0, maximum: 5 }
Row:
type: object
x-java-record: OutsourcedBusinessModels.Row
required: [dispatchNo, stage, plannedDepartureAt, loadingCompletedAt, origin, vehicle, driver, destinations, actualPalletCount, operations, recordVersion, dispatchRecordVersion, photoSummary, actions, sourceIssues]
properties:
dispatchNo: { type: string }
stage: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" }
plannedDepartureAt: { type: string, format: date-time }
loadingCompletedAt: { type: string, format: date-time }
departedAt: { type: string, format: date-time, nullable: true }
deliveryCompletedAt: { type: string, format: date-time, nullable: true }
origin: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Origin" }
vehicle: { "$ref": "#/components/schemas/Vehicle" }
driver: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Driver" }
destinations: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Destination" } }
actualPalletCount: { type: integer, minimum: 0, description: "原实载warehousePalletId去重;同客户/跨客户共托只计一次,不用标准托位作除数" }
operations: { "$ref": "#/components/schemas/Operations" }
unitPrice: { type: string, nullable: true, description: "整车金额除实际物理托数,HALF_UP两位;金额null或托数0时null" }
recordVersion: { type: string, description: "本页附加版本,初始只读虚拟0;原主单版本独立" }
dispatchRecordVersion: { type: string }
photoSummary: { "$ref": "#/components/schemas/PhotoSummary" }
actions: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Actions" }
sourceIssues: { type: array, items: { type: string } }
ListResult:
type: object
x-java-record: OutsourcedBusinessModels.ListResult
required: [records, page, size, total, summary]
properties:
records: { type: array, items: { "$ref": "#/components/schemas/Row" } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
summary: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Summary" }
Detail:
type: object
x-java-record: OutsourcedBusinessModels.Detail
required: [row, dispatch, cargo, photos]
properties:
row: { "$ref": "#/components/schemas/Row" }
dispatch: { "$ref": "overseas-business-v1.yaml#/components/schemas/DispatchDetail" }
cargo: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Cargo" } }
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
History:
type: object
x-java-record: OutsourcedBusinessModels.History
required: [eventSource, action, occurredAt, photos]
properties:
eventSource: { type: string, enum: [DISPATCH, OPERATIONS] }
recordVersion: { type: string, nullable: true }
dispatchRecordVersion: { type: string, nullable: true }
action: { type: string }
operatorName: { type: string, nullable: true }
occurredAt: { type: string, format: date-time }
reason: { type: string, nullable: true }
operations: { allOf: [ { "$ref": "#/components/schemas/Operations" } ], nullable: true }
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
HistoryPage:
type: object
x-java-record: OutsourcedBusinessModels.HistoryPage
required: [records, page, size, total]
properties:
records: { type: array, items: { "$ref": "#/components/schemas/History" } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
Command:
type: object
x-java-record: OutsourcedBusinessModels.Command
description: "SAVE_OPERATIONS/BIND_PHOTO成功时recordVersion和result必须有值;UPLOAD_PHOTO成功时result必须含fileId,recordVersion可空。仅可空的未确定或拒绝结果字段允许省略,不放宽成功回执的关联要求。"
required: [dispatchNo, action, idempotencyKey, state, replayed, retryAllowed]
properties:
dispatchNo: { type: string }
action: { type: string, enum: [SAVE_OPERATIONS, UPLOAD_PHOTO, BIND_PHOTO] }
idempotencyKey: { type: string }
state: { type: string, enum: [SUCCEEDED, PENDING, UNKNOWN_RESULT, REJECTED] }
recordVersion: { type: string, nullable: true }
replayed: { type: boolean }
result: { nullable: true, oneOf: [ { "$ref": "#/components/schemas/Detail" }, { "$ref": "#/components/schemas/Photo" } ] }
errorCode: { type: string, nullable: true }
retryAllowed: { type: boolean, enum: [false] }
ListResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/ListResult" } ], nullable: true } } }
HistoryResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/HistoryPage" } ], nullable: true } } }
PhotoResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Photo" } ], nullable: true } } }
CommandResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Command" } ], nullable: true } } }
@@ -1,943 +0,0 @@
openapi: "3.0.3"
info:
title: "原排车自有车辆运营视图"
version: "1.0.0"
description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
security:
-
bearerAuth: []
paths:
"/api/platform/v1/last-leg/fleet/owned-business":
get:
operationId: "listOwnedBusiness"
summary: "自有车辆装车后视图及同筛选四项统计"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/ListResult"
nullable: true
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: "plannedDepartureFrom"
in: "query"
required: false
schema:
type: "string"
format: "date-time"
-
name: "plannedDepartureTo"
in: "query"
required: false
schema:
type: "string"
format: "date-time"
-
name: "keyword"
in: "query"
required: false
schema:
type: "string"
maxLength: 128
-
name: "stage"
in: "query"
required: false
schema:
"$ref": "#/components/schemas/Stage"
-
name: "warehouseId"
in: "query"
required: false
schema:
type: "string"
maxLength: 64
x-filter-contract: "plannedDepartureAt [from,to),统计同全部过滤、不带分页;无归属快照不降级按当前主档推断。"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
get:
operationId: "getOwnedBusiness"
summary: "组合原排车详情及附加运营信息"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/history":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
get:
operationId: "historyOwnedBusiness"
summary: "原排车及附加真实流水聚合分页"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/HistoryPage"
nullable: true
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
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/operations":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
put:
operationId: "saveOwnedBusinessOperations"
summary: "按附加版本登记、更正或清空运营信息"
description: "权限:platform:fleet:owned-business:edit。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:edit"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
requestBody:
required: true
content:
"application/json":
schema:
"$ref": "#/components/schemas/SaveOperations"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/uploads":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
post:
operationId: "uploadOwnedBusinessPhoto"
summary: "逐框准备照片,尚未保存绑定"
description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:upload"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Photo"
nullable: true
requestBody:
required: true
content:
"multipart/form-data":
schema:
type: "object"
required:
- "file"
- "idempotencyKey"
properties:
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。"
file:
type: "string"
format: "binary"
description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
put:
operationId: "bindOwnedBusinessPhoto"
summary: "保存原上传fileId;失败保留原键恢复,不重新上传"
description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:upload"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
requestBody:
required: true
content:
"application/json":
schema:
"$ref": "#/components/schemas/BindPhoto"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/files/{fileId}/content":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
-
name: "fileId"
in: "path"
required: true
schema:
type: "string"
pattern: "^[1-9][0-9]*$"
get:
operationId: "readOwnedBusinessPhoto"
summary: "授权读取当前或原历史绑定图片"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "仅当前或指定历史引用的图片字节,Cache-Control:no-store,X-Content-Type-Options:nosniff"
content:
"image/jpeg":
schema:
type: "string"
format: "binary"
"image/png":
schema:
type: "string"
format: "binary"
parameters:
-
name: "revisionVersion"
in: "query"
required: false
schema:
type: "integer"
format: "int64"
minimum: 1
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/commands/{idempotencyKey}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "idempotencyKey"
in: "path"
required: true
schema:
type: "string"
minLength: 8
maxLength: 128
get:
operationId: "recoverOwnedBusinessCommand"
summary: "恢复本操作者原命令,未知结果不得换键重发"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Command"
nullable: true
components:
securitySchemes:
bearerAuth:
type: "http"
scheme: "bearer"
bearerFormat: "JWT"
schemas:
Stage:
type: "string"
enum:
- "AFTER_LOADING"
- "DELIVERING"
- "DELIVERED"
PhotoSlot:
type: "string"
enum:
- "LOADING_SCENE"
- "LOADING_LIST"
- "PRE_DEPARTURE_CHECK"
- "DRIVER_LICENSE"
- "RECEIPT"
- "FUEL_START"
- "FUEL_END"
Money:
type: "object"
x-java-record: "OwnedBusinessModels.Money"
required:
- "currency"
- "amount"
properties:
currency:
type: "string"
enum:
- "CNY"
- "USD"
amount:
type: "string"
pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$"
Arrival:
type: "object"
x-java-record: "OwnedBusinessModels.Arrival"
required:
- "destinationKey"
properties:
destinationKey:
type: "string"
minLength: 1
maxLength: 256
actualArrivedAt:
type: "string"
nullable: true
format: "date-time"
Operations:
type: "object"
x-java-record: "OwnedBusinessModels.Operations"
required:
- "arrivals"
properties:
money:
allOf:
-
"$ref": "#/components/schemas/Money"
nullable: true
actualDistanceMiles:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$"
fuelConsumptionLitresPer100Km:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$"
arrivals:
type: "array"
items:
"$ref": "#/components/schemas/Arrival"
SaveOperations:
type: "object"
x-java-record: "OwnedBusinessModels.SaveOperations"
required:
- "recordVersion"
- "idempotencyKey"
- "operations"
properties:
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
operations:
"$ref": "#/components/schemas/Operations"
reason:
type: "string"
nullable: true
maxLength: 1000
BindPhoto:
type: "object"
x-java-record: "OwnedBusinessModels.BindPhoto"
required:
- "recordVersion"
- "idempotencyKey"
- "fileId"
properties:
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
fileId:
type: "string"
pattern: "^[1-9][0-9]*$"
Gate:
type: "object"
x-java-record: "OwnedBusinessModels.Gate"
required:
- "allowed"
properties:
allowed:
type: "boolean"
blockReason:
type: "string"
nullable: true
Actions:
type: "object"
x-java-record: "OwnedBusinessModels.Actions"
required:
- "edit"
- "upload"
properties:
edit:
"$ref": "#/components/schemas/Gate"
upload:
"$ref": "#/components/schemas/Gate"
Origin:
type: "object"
x-java-record: "OwnedBusinessModels.Origin"
required:
- "warehouseId"
- "warehouseCode"
- "warehouseName"
properties:
warehouseId:
type: "string"
warehouseCode:
type: "string"
warehouseName:
type: "string"
Vehicle:
type: "object"
x-java-record: "OwnedBusinessModels.Vehicle"
required:
- "vehicleId"
- "plate"
- "ownership"
properties:
vehicleId:
type: "string"
plate:
type: "string"
ownership:
type: "string"
enum:
- "OWNED"
Driver:
type: "object"
x-java-record: "OwnedBusinessModels.Driver"
properties:
name:
type: "string"
nullable: true
phone:
type: "string"
nullable: true
Appointment:
type: "object"
x-java-record: "OwnedBusinessModels.Appointment"
required:
- "appointmentNo"
- "allocatedPackages"
properties:
appointmentNo:
type: "string"
confirmationNo:
type: "string"
nullable: true
scheduledAt:
type: "string"
nullable: true
format: "date-time"
allocatedPackages:
type: "string"
pattern: "^[1-9][0-9]*$"
Destination:
type: "object"
x-java-record: "OwnedBusinessModels.Destination"
required:
- "destinationKey"
- "appointments"
properties:
destinationKey:
type: "string"
warehouseCode:
type: "string"
nullable: true
warehouseName:
type: "string"
nullable: true
address:
type: "string"
nullable: true
appointments:
type: "array"
items:
"$ref": "#/components/schemas/Appointment"
actualArrivedAt:
type: "string"
nullable: true
format: "date-time"
Photo:
type: "object"
x-java-record: "OwnedBusinessModels.Photo"
required:
- "slot"
- "category"
- "fileId"
- "fileName"
- "contentType"
- "sizeBytes"
- "uploadedAt"
properties:
slot:
"$ref": "#/components/schemas/PhotoSlot"
category:
type: "string"
enum:
- "LOADING"
- "FUEL"
fileId:
type: "string"
fileName:
type: "string"
contentType:
type: "string"
enum:
- "image/jpeg"
- "image/png"
sizeBytes:
type: "string"
pattern: "^[1-9][0-9]*$"
uploadedAt:
type: "string"
savedAt:
type: "string"
nullable: true
format: "date-time"
description: "上传回执savedAt为空;绑定成功才填写savedAt。File类型分别为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,平台0、原dispatch内部ID,最多10MiB。"
PhotoSummary:
type: "object"
x-java-record: "OwnedBusinessModels.PhotoSummary"
required:
- "loadingCount"
- "fuelCount"
properties:
loadingCount:
type: "integer"
minimum: 0
fuelCount:
type: "integer"
minimum: 0
Row:
type: "object"
x-java-record: "OwnedBusinessModels.Row"
required:
- "dispatchNo"
- "stage"
- "plannedDepartureAt"
- "loadingCompletedAt"
- "origin"
- "vehicle"
- "driver"
- "destinations"
- "operations"
- "recordVersion"
- "dispatchRecordVersion"
- "photoSummary"
- "actions"
- "sourceIssues"
properties:
dispatchNo:
type: "string"
stage:
"$ref": "#/components/schemas/Stage"
plannedDepartureAt:
type: "string"
format: "date-time"
loadingCompletedAt:
type: "string"
format: "date-time"
departedAt:
type: "string"
nullable: true
format: "date-time"
deliveryCompletedAt:
type: "string"
nullable: true
format: "date-time"
origin:
"$ref": "#/components/schemas/Origin"
vehicle:
"$ref": "#/components/schemas/Vehicle"
driver:
"$ref": "#/components/schemas/Driver"
destinations:
type: "array"
items:
"$ref": "#/components/schemas/Destination"
actualPalletCount:
type: "integer"
minimum: 0
nullable: true
operations:
"$ref": "#/components/schemas/Operations"
unitPrice:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
dispatchRecordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
photoSummary:
"$ref": "#/components/schemas/PhotoSummary"
actions:
"$ref": "#/components/schemas/Actions"
sourceIssues:
type: "array"
items:
type: "string"
Summary:
type: "object"
x-java-record: "OwnedBusinessModels.Summary"
required:
- "dispatchCount"
- "afterLoadingCount"
- "deliveringCount"
- "deliveredCount"
properties:
dispatchCount:
type: "integer"
minimum: 0
afterLoadingCount:
type: "integer"
minimum: 0
deliveringCount:
type: "integer"
minimum: 0
deliveredCount:
type: "integer"
minimum: 0
ListResult:
type: "object"
x-java-record: "OwnedBusinessModels.ListResult"
required:
- "records"
- "page"
- "size"
- "total"
- "summary"
properties:
records:
type: "array"
items:
"$ref": "#/components/schemas/Row"
page:
type: "integer"
minimum: 1
size:
type: "integer"
minimum: 1
maximum: 200
total:
type: "integer"
minimum: 0
summary:
"$ref": "#/components/schemas/Summary"
Cargo:
type: "object"
x-java-record: "OwnedBusinessModels.Cargo"
required:
- "sourceKey"
- "customerId"
- "palletNo"
- "destinationKey"
- "packages"
properties:
sourceKey:
type: "string"
customerId:
type: "string"
customerName:
type: "string"
nullable: true
orderNo:
type: "string"
nullable: true
inboundCode:
type: "string"
nullable: true
palletNo:
type: "string"
destinationKey:
type: "string"
packages:
type: "string"
Detail:
type: "object"
x-java-record: "OwnedBusinessModels.Detail"
required:
- "row"
- "dispatch"
- "cargo"
- "photos"
properties:
row:
"$ref": "#/components/schemas/Row"
dispatch:
"$ref": "./overseas-business-v1.yaml#/components/schemas/DispatchDetail"
cargo:
type: "array"
items:
"$ref": "#/components/schemas/Cargo"
photos:
type: "array"
items:
"$ref": "#/components/schemas/Photo"
History:
type: "object"
x-java-record: "OwnedBusinessModels.History"
required:
- "eventSource"
- "action"
- "occurredAt"
- "photos"
properties:
eventSource:
type: "string"
enum:
- "DISPATCH"
- "OPERATIONS"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
dispatchRecordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
action:
type: "string"
operatorName:
type: "string"
nullable: true
occurredAt:
type: "string"
format: "date-time"
reason:
type: "string"
nullable: true
operations:
allOf:
-
"$ref": "#/components/schemas/Operations"
nullable: true
photos:
type: "array"
items:
"$ref": "#/components/schemas/Photo"
description: "UNION ALL读取原排车真实流水与附加流水,一次排序分页;DISPATCH事件recordVersion=null、operations=null、photos=[];OPERATIONS事件dispatchRecordVersion=null。"
HistoryPage:
type: "object"
x-java-record: "OwnedBusinessModels.HistoryPage"
required:
- "records"
- "page"
- "size"
- "total"
properties:
records:
type: "array"
items:
"$ref": "#/components/schemas/History"
page:
type: "integer"
minimum: 1
size:
type: "integer"
minimum: 1
maximum: 200
total:
type: "integer"
minimum: 0
Command:
type: "object"
x-java-record: "OwnedBusinessModels.Command"
description: "SAVE_OPERATIONS/BIND_PHOTO成功时recordVersion和result必须有值;UPLOAD_PHOTO成功时result必须含fileId,recordVersion可空。仅可空的未确定或拒绝结果字段允许省略,不放宽成功回执的关联要求。"
required:
- "dispatchNo"
- "action"
- "idempotencyKey"
- "state"
- "replayed"
- "retryAllowed"
properties:
dispatchNo:
type: "string"
action:
type: "string"
enum:
- "SAVE_OPERATIONS"
- "UPLOAD_PHOTO"
- "BIND_PHOTO"
idempotencyKey:
type: "string"
state:
type: "string"
enum:
- "SUCCEEDED"
- "PENDING"
- "UNKNOWN_RESULT"
- "REJECTED"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
replayed:
type: "boolean"
result:
nullable: true
oneOf:
-
"$ref": "#/components/schemas/Detail"
-
"$ref": "#/components/schemas/Photo"
errorCode:
type: "string"
nullable: true
retryAllowed:
type: "boolean"
enum:
- false

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