Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6877c372bd | ||
|
|
3ceafa9ca7 | ||
|
|
a43c4d3321 | ||
|
|
2ec4627db6 | ||
|
|
c324de9c32 | ||
|
|
9f3755f8e2 | ||
|
|
8e9cbf6aa2 |
@@ -1,7 +1,5 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总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 数据不倒推历史归属,匹配版本按新原生样本验收。
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -6848,6 +6862,12 @@ 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,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);
|
||||
@@ -8377,4 +8397,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。
|
||||
@@ -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 '起运海外仓编码快照',
|
||||
@@ -1657,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history (
|
||||
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
|
||||
CREATE TABLE ll_order (
|
||||
id bigint NOT NULL COMMENT '雪花 ID',
|
||||
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
|
||||
@@ -1866,12 +1871,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 +3222,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 +3236,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,7 +4888,6 @@ 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',
|
||||
@@ -4971,42 +4967,4 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||
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, 233 AS initialized_tables;
|
||||
|
||||
+4
-4
@@ -64,13 +64,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
|
||||
|
||||
@@ -17,8 +17,7 @@ public interface WorkflowFactMapper {
|
||||
+"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 "
|
||||
+"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 "
|
||||
+"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);
|
||||
|
||||
@@ -5,7 +5,7 @@ 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) {}
|
||||
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {}
|
||||
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) {}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class WorkflowTabService {
|
||||
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 {
|
||||
@@ -56,18 +56,16 @@ public class WorkflowTabService {
|
||||
Set<String> keys=new LinkedHashSet<>();
|
||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||
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");
|
||||
|
||||
@@ -392,7 +392,7 @@ paths:
|
||||
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}
|
||||
|
||||
-7
@@ -30,7 +30,6 @@ 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);
|
||||
@@ -96,12 +95,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'");
|
||||
|
||||
@@ -13,7 +13,7 @@ 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",true,false,false,false);
|
||||
}
|
||||
|
||||
/** 下游漏来源或返回其他客户均整体失败。 */
|
||||
@@ -33,23 +33,4 @@ 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)))));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,46 +3,33 @@ import org.junit.jupiter.api.Test;
|
||||
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 missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false);
|
||||
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true);
|
||||
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true);
|
||||
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true);
|
||||
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true);
|
||||
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);
|
||||
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false);
|
||||
}
|
||||
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
|
||||
@Test void cancellationRestoresDispatchQueue() {
|
||||
@@ -58,9 +45,9 @@ class WorkflowBusinessTest {
|
||||
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
|
||||
@Test void independentSlicesAndUploadedClearance() {
|
||||
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
|
||||
.containsExactly("SAILING_CLEARANCE");
|
||||
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
|
||||
.containsExactly("OUTBOUND_DOCUMENT");
|
||||
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
|
||||
assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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->{
|
||||
|
||||
@@ -101,6 +101,9 @@ VALUES
|
||||
(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),
|
||||
@@ -164,7 +167,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: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: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')
|
||||
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;
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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))");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const ownedBusinessReadEdges = [
|
||||
['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();
|
||||
@@ -49,16 +50,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(), ['dispatch-delivery', '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);
|
||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
|
||||
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);
|
||||
@@ -70,13 +68,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
|| 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.ownedbusiness.port.OwnedBusinessOwnershipCapture'),
|
||||
'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);
|
||||
@@ -101,6 +93,21 @@ test('owned business reads only reviewed dispatch ports and cannot inherit persi
|
||||
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,
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
+3
-7
@@ -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},
|
||||
|
||||
+2
-1
@@ -261,7 +261,8 @@ 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 > 0 " +
|
||||
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
|
||||
"AND pos.hold_packages > 0 AND pos.pallet_id > 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 " +
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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();
|
||||
}
|
||||
+23
@@ -11,6 +11,8 @@ 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) { }
|
||||
@@ -112,6 +114,27 @@ 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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-6
@@ -2,7 +2,7 @@ package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -11,17 +11,17 @@ import java.time.LocalDate;
|
||||
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
|
||||
@Repository
|
||||
public class DispatchNumberSequence {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final DispatchNumberSequenceMapper mapper;
|
||||
|
||||
/** 流水与排车主单使用相同服务数据库。 */
|
||||
public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long next(LocalDate date) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date);
|
||||
Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||
// 两个实际 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;
|
||||
|
||||
+18
@@ -75,6 +75,24 @@ 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); }
|
||||
/** 锁定当前活动资源占用。 */
|
||||
|
||||
+13
-8
@@ -32,6 +32,7 @@ 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;
|
||||
@@ -149,9 +150,13 @@ 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:
|
||||
@@ -181,6 +186,12 @@ 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);
|
||||
}
|
||||
|
||||
@@ -647,7 +658,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));
|
||||
@@ -754,12 +765,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()) {
|
||||
@@ -813,7 +818,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));
|
||||
}
|
||||
|
||||
|
||||
+69
-8
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -34,16 +35,64 @@ 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);
|
||||
var group=sourceGroup(row,false,reads);
|
||||
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();
|
||||
@@ -78,6 +127,11 @@ 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>();
|
||||
@@ -90,7 +144,9 @@ 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(Objects::nonNull).forEach(pallets::add);
|
||||
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
|
||||
.filter(id->id!=null && id>0).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());
|
||||
@@ -109,7 +165,8 @@ public class DispatchService {
|
||||
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
|
||||
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
|
||||
if(!pallets.isEmpty()) {
|
||||
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
|
||||
reads.read(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()) {
|
||||
@@ -130,7 +187,8 @@ public class DispatchService {
|
||||
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
|
||||
}
|
||||
}
|
||||
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
|
||||
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));
|
||||
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>();
|
||||
@@ -397,7 +455,7 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
|
||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
|
||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
|
||||
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());
|
||||
@@ -409,12 +467,15 @@ 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 stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
|
||||
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();
|
||||
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
|
||||
facts.put("stock",stock);
|
||||
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
|
||||
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
||||
facts.put("receipts",basis.receipts());
|
||||
facts.put("returnedReceipts",basis.returnedReceipts());
|
||||
return wholeHashes.source(facts);
|
||||
}
|
||||
|
||||
|
||||
-91
@@ -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();
|
||||
}
|
||||
}
|
||||
-42
@@ -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);
|
||||
}
|
||||
-23
@@ -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) { }
|
||||
}
|
||||
-84
@@ -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);}}
|
||||
}
|
||||
-34
@@ -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(); }
|
||||
}
|
||||
-158
@@ -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);}
|
||||
}
|
||||
-29
@@ -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 != ""'>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);
|
||||
}
|
||||
-28
@@ -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); }
|
||||
}
|
||||
-52
@@ -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); }
|
||||
}
|
||||
+4
-10
@@ -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) {
|
||||
|
||||
+1
-3
@@ -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);
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
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);}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
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);}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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());}
|
||||
}
|
||||
+9
-3
@@ -68,14 +68,20 @@ public interface OwnedBusinessMapper {
|
||||
#{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,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||
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,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||
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);
|
||||
|
||||
+5
-1
@@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper {
|
||||
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='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
|
||||
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 >= #{q.from}</if>
|
||||
<if test='q.to != null'> AND d.planned_departure_at < #{q.to}</if>
|
||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||
|
||||
+1
-1
@@ -52,6 +52,6 @@ public final class OwnedBusinessRows {
|
||||
private Long allocatedPackages;
|
||||
}
|
||||
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
|
||||
LocalDateTime from,LocalDateTime to) {}
|
||||
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
|
||||
@Data public static class StageCount { private String stage;private Long quantity; }
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
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?"自有":"外配")+"业务");}
|
||||
}
|
||||
+31
-10
@@ -3,6 +3,7 @@ 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.*;
|
||||
@@ -32,7 +33,12 @@ public class OwnedBusinessPhotoService {
|
||||
public record Download(Photo photo,byte[] content) {}
|
||||
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
|
||||
codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number);
|
||||
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("无法读取待上传照片");}
|
||||
@@ -49,41 +55,56 @@ public class OwnedBusinessPhotoService {
|
||||
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
|
||||
} catch(RuntimeException failure) {
|
||||
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");}
|
||||
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) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失");
|
||||
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=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
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);
|
||||
}
|
||||
return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId)));
|
||||
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) {
|
||||
codec.require(actor,LIST);var source=queries.requireOwned(number);List<Photo> bound;
|
||||
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 missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||
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(OwnedBusinessSupport::missing);
|
||||
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) {
|
||||
codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor);
|
||||
if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||
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);
|
||||
}
|
||||
|
||||
+16
-2
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -45,9 +46,15 @@ public class OwnedBusinessProjection {
|
||||
}
|
||||
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
|
||||
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) {
|
||||
@@ -61,11 +68,18 @@ public class OwnedBusinessProjection {
|
||||
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(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"),
|
||||
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,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||
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) {
|
||||
|
||||
+44
-14
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -29,12 +30,19 @@ public class OwnedBusinessQueryService {
|
||||
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public ListResult list(Actor actor,Query query) {
|
||||
codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失");
|
||||
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));
|
||||
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());
|
||||
@@ -44,18 +52,26 @@ public class OwnedBusinessQueryService {
|
||||
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);return projection.row(s,records.get(s.getId()),
|
||||
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);}).toList();
|
||||
return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered));
|
||||
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) {
|
||||
eligible(source);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);
|
||||
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(),
|
||||
@@ -64,23 +80,37 @@ public class OwnedBusinessQueryService {
|
||||
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
|
||||
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);return source;
|
||||
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) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务");
|
||||
if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing();
|
||||
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();
|
||||
if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId())
|
||||
|| !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
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) {
|
||||
codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(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;
|
||||
|
||||
+22
-8
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -30,9 +31,16 @@ public class OwnedBusinessRecordService {
|
||||
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,SaveOperations request) {
|
||||
codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
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=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
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);
|
||||
@@ -42,13 +50,18 @@ public class OwnedBusinessRecordService {
|
||||
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());
|
||||
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) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number);
|
||||
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);}
|
||||
@@ -60,7 +73,7 @@ public class OwnedBusinessRecordService {
|
||||
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));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
||||
}
|
||||
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
||||
private String photoReason(PhotoSlot slot) {
|
||||
@@ -104,13 +117,14 @@ public class OwnedBusinessRecordService {
|
||||
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
|
||||
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) {
|
||||
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=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||
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) {
|
||||
|
||||
+6
-1
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -24,9 +25,13 @@ public class OwnedBusinessSupport {
|
||||
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,"仅平台可管理自有业务");
|
||||
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();
|
||||
|
||||
+1
-10
@@ -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) { }
|
||||
|
||||
+1
-11
@@ -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);
|
||||
|
||||
-20
@@ -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());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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());
|
||||
|
||||
-3
@@ -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) { }
|
||||
+5
-4
@@ -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);
|
||||
|
||||
+2
@@ -68,7 +68,9 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -149,8 +149,9 @@ 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) {
|
||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
|
||||
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
|
||||
|| position.getPalletId() <= 0) {
|
||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
|
||||
bound = false;
|
||||
}
|
||||
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
|
||||
|
||||
-3
@@ -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);
|
||||
|
||||
|
||||
-2
@@ -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); }
|
||||
/** 保存新订单。 */
|
||||
|
||||
+1
-1
@@ -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()); }
|
||||
/** 读取订单资料与实际托盘和操作记录。 */
|
||||
|
||||
@@ -45,7 +45,7 @@ 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='排车单号每日独立原子流水';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order (
|
||||
id BIGINT NOT NULL COMMENT '雪花 ID',
|
||||
@@ -1208,7 +1208,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 +1222,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 +1898,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 '起运海外仓编码快照',
|
||||
@@ -4563,7 +4560,6 @@ CREATE TABLE IF NOT EXISTS 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',
|
||||
@@ -4642,28 +4638,3 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||
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
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: "原排车外配车辆运营视图"
|
||||
version: "1.0.0"
|
||||
description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。"
|
||||
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: [money, 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: [vehicleId, plate, ownership, carrierName]
|
||||
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, savedAt]
|
||||
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, departedAt, deliveryCompletedAt, origin, vehicle, driver, destinations, actualPalletCount, operations, unitPrice, 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, recordVersion, dispatchRecordVersion, action, operatorName, occurredAt, reason, operations, 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
|
||||
required: [dispatchNo, action, idempotencyKey, state, recordVersion, replayed, result, errorCode, 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 } } }
|
||||
@@ -237,15 +237,6 @@ paths:
|
||||
-
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
nullable: true
|
||||
parameters:
|
||||
-
|
||||
name: "idempotencyKey"
|
||||
in: "query"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -254,7 +245,13 @@ paths:
|
||||
type: "object"
|
||||
required:
|
||||
- "file"
|
||||
- "idempotencyKey"
|
||||
properties:
|
||||
idempotencyKey:
|
||||
type: "string"
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。"
|
||||
file:
|
||||
type: "string"
|
||||
format: "binary"
|
||||
|
||||
+29
-5
@@ -1,6 +1,10 @@
|
||||
package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
@@ -18,22 +22,29 @@ class DispatchNumberSequenceTest {
|
||||
final JdbcTemplate jdbc;
|
||||
final DataSourceTransactionManager transactions;
|
||||
final DispatchNumberSequence sequence;
|
||||
Fixture() {
|
||||
final DispatchNumberSequenceMapper mapper;
|
||||
/** 隔离库造表只使用测试 JDBC,被测分配和读取均由实际 Mapper 执行。 */
|
||||
Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)");
|
||||
var proxy=new ProxyFactory(new DispatchNumberSequence(jdbc));proxy.setProxyTargetClass(true);
|
||||
var configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class);
|
||||
var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||
sequence=(DispatchNumberSequence)proxy.getProxy();
|
||||
}
|
||||
}
|
||||
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() {
|
||||
/** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */
|
||||
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception {
|
||||
var f=new Fixture();var today=LocalDate.of(2026,10,20);
|
||||
assertThat(f.sequence.next(today)).isEqualTo(1);
|
||||
assertThat(f.sequence.next(today)).isEqualTo(2);
|
||||
assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1);
|
||||
assertThat(f.sequence.next(today)).isEqualTo(3);
|
||||
}
|
||||
/** 两个真实事务连接争用同一天主键,全部已提交号码唯一且连续。 */
|
||||
@Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception {
|
||||
var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
@@ -43,17 +54,30 @@ class DispatchNumberSequenceTest {
|
||||
assertThat(values.last()).isEqualTo(64L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
@Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() {
|
||||
/** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */
|
||||
@Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() throws Exception {
|
||||
var f=new Fixture();var date=LocalDate.of(2026,10,20);
|
||||
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||
assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(f.sequence.next(date)).isEqualTo(2);
|
||||
}
|
||||
@Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() {
|
||||
/** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */
|
||||
@Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() throws Exception {
|
||||
var f=new Fixture();var date=LocalDate.of(2026,10,20);
|
||||
f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date);
|
||||
assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满");
|
||||
assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L);
|
||||
}
|
||||
|
||||
/** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */
|
||||
@Test void connectionResultBypassesSessionCacheInTheSameTransaction() throws Exception {
|
||||
var f=new Fixture();
|
||||
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
-19
@@ -442,23 +442,6 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10);
|
||||
}
|
||||
|
||||
/** 到仓取货、本土卡车不制造货物明细,但仍须按原客户出现在现有整车排车列表。 */
|
||||
@Test void customerFilterFindsPickupAndLocalTruckByTheirRealSourceLinks() throws Exception {
|
||||
var f=new Fixture();
|
||||
var pickup=f.tx.execute(s->f.service.create(f.actor,f.create("transport-pickup-list",List.of())));
|
||||
var truck=f.tx.execute(s->f.service.create(f.actor,f.create("transport-truck-list",List.of())));
|
||||
String pickupTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,pickup.dispatchNo());
|
||||
String truckTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,truck.dispatchNo());
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",pickup.dispatchNo());
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",truck.dispatchNo());
|
||||
f.db.update("INSERT INTO ll_fleet_pickup_task(customer_id,trip_no) VALUES('PICKUP-CUSTOMER',?)",pickupTrip);
|
||||
f.db.update("INSERT INTO ll_dispatch_truck_task(dispatch_no,customer_id) VALUES(?,'TRUCK-CUSTOMER')",truck.dispatchNo());
|
||||
var pickupPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"PICKUP-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
|
||||
var truckPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"TRUCK-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
|
||||
assertThat(pickupPage.total()).isOne();assertThat(pickupPage.records().get(0).dispatchNo()).isEqualTo(pickup.dispatchNo());assertThat(pickupPage.records().get(0).taskType()).isEqualTo("PICKUP");
|
||||
assertThat(truckPage.total()).isOne();assertThat(truckPage.records().get(0).dispatchNo()).isEqualTo(truck.dispatchNo());assertThat(truckPage.records().get(0).taskType()).isEqualTo("LOCAL_TRUCK");
|
||||
}
|
||||
|
||||
/** 第N项已被另一车占用时,早先本车/来源/资源/账本全部回滚。 */
|
||||
@Test void laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems() throws Exception {
|
||||
var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5);
|
||||
@@ -509,8 +492,6 @@ public class DispatchAggregatePersistenceTest {
|
||||
db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)");
|
||||
db.execute("CREATE TABLE ll_dispatch_truck_task(dispatch_no VARCHAR(64),customer_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_fleet_pickup_task(customer_id VARCHAR(128),trip_no VARCHAR(64))");
|
||||
db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
|
||||
+27
@@ -148,6 +148,33 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4);
|
||||
}
|
||||
|
||||
/** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */
|
||||
@ParameterizedTest @ValueSource(longs={0,-1})
|
||||
void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception {
|
||||
var f=new Merged(false);
|
||||
for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601,
|
||||
"overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id,
|
||||
"warehouse_location_code","LOC-1","hold_packages",1);
|
||||
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源",
|
||||
"total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING",
|
||||
"operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102,
|
||||
"source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING",
|
||||
"destination_fba_warehouse_code","ONT8");
|
||||
f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED");
|
||||
var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null));
|
||||
var groups=f.rules.selectionGroups(candidates);
|
||||
assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L));
|
||||
var group=groups.get("RELABEL:RL-A:501");
|
||||
assertThat(group.complete()).isFalse();
|
||||
assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失"));
|
||||
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo)
|
||||
.containsExactlyInAnyOrder("RL-A","RL-B");
|
||||
assertThat(group.pallets()).hasSize(1);
|
||||
assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList()))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
|
||||
}
|
||||
|
||||
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
|
||||
private static final class Merged {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
|
||||
+68
-5
@@ -13,6 +13,67 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 候选投影在此受控,来源订单/仓/库存版本/实收组成与锁序均经实际MyBatis查询。 */
|
||||
class DispatchWholeSourcePersistenceTest {
|
||||
/** 同页普通来源批读不随托盘逐行重复,完整组与版本须和单组权威读取完全一致。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={true,false})
|
||||
void batchesPageSourceReadsWithoutChangingGroupsOrReusingThemAcrossRequests(boolean sameCustomer) throws Exception {
|
||||
var fixture=new Sources(sameCustomer);var selections=fixture.selections();
|
||||
var first=fixture.rules.selectionGroup("9",selections.get(0));
|
||||
var second=fixture.rules.selectionGroup("9",selections.get(1));
|
||||
clearInvocations(fixture.sourceMapper,fixture.candidateMapper);
|
||||
var rows=fixture.f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList();
|
||||
var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
|
||||
assertThat(groups.get("PLAN:1")).isEqualTo(first);
|
||||
assertThat(groups.get("PLAN:2")).isEqualTo(second);
|
||||
verify(fixture.sourceMapper,times(1)).physicalPlans("9",List.of(501L,502L));
|
||||
verify(fixture.sourceMapper,times(1)).physicalReturns("9",List.of(501L,502L));
|
||||
if(sameCustomer) {
|
||||
verify(fixture.candidateMapper,times(1)).readSelected("101",List.of(1L,2L));
|
||||
verify(fixture.candidateMapper,never()).readSelected("101",List.of(1L));
|
||||
}
|
||||
var customers=sameCustomer?Map.of("101",List.of(501L,502L)):Map.of("101",List.of(501L),"202",List.of(502L));
|
||||
customers.forEach((customer,pallets)->{
|
||||
verify(fixture.sourceMapper,times(1)).stocks(customer,pallets);
|
||||
verify(fixture.sourceMapper,times(1)).palletReceipts(customer,pallets);
|
||||
verify(fixture.sourceMapper,times(1)).palletReturnedReceipts(customer,pallets);
|
||||
});
|
||||
verify(fixture.sourceMapper,never()).stock(anyString(),anyLong(),anyBoolean());
|
||||
verify(fixture.sourceMapper,never()).receipts(anyString(),anyLong());
|
||||
verify(fixture.sourceMapper,never()).returnedReceipts(anyString(),anyLong());
|
||||
verify(fixture.sourceMapper,never()).physicalPlans("9",List.of(501L));
|
||||
fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=4 WHERE id=1");
|
||||
var refreshed=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
|
||||
assertThat(refreshed.get("PLAN:1").groupVersion()).isNotEqualTo(first.groupVersion());
|
||||
verify(fixture.sourceMapper,times(2)).physicalPlans("9",List.of(501L,502L));
|
||||
}
|
||||
/** 未实收计划没有实托,整页版本生成不能逐条发空库存和实收查询。 */
|
||||
@Test void plannedCandidatesNeedNoPhysicalFactQueries() throws Exception {
|
||||
var fixture=new Sources(true);
|
||||
var rows=fixture.f.candidates.values().stream().map(row->row.warehousePalletId(null).sourceStage("PLAN")).toList();
|
||||
var expected=new HashMap<String,String>();
|
||||
for(var row:rows)expected.put("PLAN:"+row.planPalletId(),fixture.rules.wholeSourceVersion(row,"PLAN:"+row.planPalletId(),false));
|
||||
clearInvocations(fixture.sourceMapper,fixture.candidateMapper);
|
||||
var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
|
||||
for(var entry:expected.entrySet())assertThat(groups.get(entry.getKey()).sources().get(0).selection().sourceVersion()).isEqualTo(entry.getValue());
|
||||
verifyNoInteractions(fixture.sourceMapper,fixture.candidateMapper);
|
||||
}
|
||||
/** 同实托不同客户的收货依据必须隔离,真实退回实收仍参加摘要且保持原顺序。 */
|
||||
@Test void batchFactsMatchIndividualQueriesForSharedPalletCustomersAndReturns() throws Exception {
|
||||
var fixture=new Sources();
|
||||
fixture.f.db.update("INSERT INTO ll_inventory_position VALUES(3,'202',3,501,'PALLET-501','9',2,0,0,0,1)");
|
||||
fixture.f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(3,'202','IN-OTHER',9002,3,2,501)");
|
||||
fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(5,'101',1,9001,1,2)");
|
||||
fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'202',3,9002,3,1)");
|
||||
var repository=new DispatchSourceRepository(fixture.candidateMapper,mock(ReturnDispatchCandidateMapper.class),fixture.sourceMapper);
|
||||
for(var customer:List.of("101","202")) {
|
||||
var batch=repository.sourceFacts(customer,List.of(501L,502L));
|
||||
for(var pallet:List.of(501L,502L)) {
|
||||
assertThat(batch.get(pallet).stock()).isEqualTo(repository.stock(customer,pallet,false));
|
||||
assertThat(batch.get(pallet).receipts()).isEqualTo(repository.receipts(customer,pallet));
|
||||
assertThat(batch.get(pallet).returnedReceipts()).isEqualTo(repository.returnedReceipts(customer,pallet));
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 同仓多客户先锁齐来源订单,随后稳定锁计划和库存,不以MIN展示订单替代组成。 */
|
||||
@Test void resolvesMultipleCustomersAndLocksAllSourceOrdersBeforeInventory() throws Exception {
|
||||
var fixture=new Sources();var selections=fixture.selections();clearInvocations(fixture.sourceMapper);
|
||||
@@ -43,10 +104,12 @@ class DispatchWholeSourcePersistenceTest {
|
||||
/** 实际来源表保持独立客户;只有复杂候选投影查询在本用例隔离。 */
|
||||
private static final class Sources {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchSourceMapper sourceMapper;final DispatchService rules=new DispatchService();
|
||||
final DispatchSourceMapper sourceMapper;final DispatchCandidateMapper candidateMapper;final DispatchService rules=new DispatchService();
|
||||
/** 构造真实订单组成和库存版本,不能由候选响应替代这些数据库事实。 */
|
||||
Sources() throws Exception {
|
||||
f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5);
|
||||
Sources() throws Exception { this(false); }
|
||||
/** 同客户多托用于核对候选批读次数,跨客户夹具继续覆盖原隔离与锁序。 */
|
||||
Sources(boolean sameCustomer) throws Exception {
|
||||
f.source(1,"101",501,"ONT8",3);f.source(2,sameCustomer?"101":"202",502,"LAX9",5);
|
||||
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
|
||||
f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)");
|
||||
f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)");
|
||||
@@ -60,9 +123,9 @@ class DispatchWholeSourcePersistenceTest {
|
||||
f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount());
|
||||
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId());
|
||||
}
|
||||
var plans=mock(DispatchCandidateMapper.class);
|
||||
var plans=mock(DispatchCandidateMapper.class);candidateMapper=plans;
|
||||
when(plans.findCustomerScopesByPlanPalletIds(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return List.of(f.candidates.get(ids.get(0)).customerId());});
|
||||
when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(1);return List.of(f.candidates.get(ids.get(0)));});
|
||||
when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(1);return ids.stream().map(f.candidates::get).toList();});
|
||||
sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class));
|
||||
var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper);
|
||||
ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
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.trucking.entity.UsTruckOrder;
|
||||
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class TransportTaskProjectionTest {
|
||||
/** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */
|
||||
@Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){
|
||||
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class);
|
||||
var projection=new TransportTaskProjection(roots,links,trucks,mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules());
|
||||
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setRecordVersion(3L);root.setTaskType("LOCAL_TRUCK");
|
||||
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,3L)).thenReturn(1);
|
||||
when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));
|
||||
var order=new UsTruckOrder();order.setId(9L);order.setStatus("DISPATCHED");order.setVersion(5L);when(trucks.lockNo("C1","U1")).thenReturn(order);
|
||||
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("CANCELLED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now());
|
||||
projection.changed(new TransportTripChanged(trip,"CANCEL","取消运输","OP"));
|
||||
assertThat(root.getStatus()).isEqualTo("CANCELLED");assertThat(root.getRecordVersion()).isEqualTo(4L);assertThat(order.getStatus()).isEqualTo("PENDING_DISPATCH");assertThat(order.getVersion()).isEqualTo(6L);
|
||||
verify(links).cancel("PC1");verify(trucks).insertHistory(argThat(row->"DISPATCHED".equals(row.getFromStatus())&&"PENDING_DISPATCH".equals(row.getToStatus())&&"C1".equals(row.getCustomerId())));
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
package com.baiyue.lastleg.dispatch.transport;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
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.junit.jupiter.api.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 验证原单承运执行权、凭证隔离和成功请求重放,不能仅靠页面按钮门禁。 */
|
||||
class TransportTaskServiceTest {
|
||||
DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips;
|
||||
UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order;
|
||||
final Map<String,String> commands=new HashMap<>();
|
||||
@BeforeEach void setup(){
|
||||
roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class);
|
||||
trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class);
|
||||
service=new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,mock(FleetPickupService.class),mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules());
|
||||
var root=new DispatchAggregate();root.setDispatchNo("PC1");root.setTaskType("LOCAL_TRUCK");root.setTripId(1L);
|
||||
when(roots.find("PC1")).thenReturn(root);when(links.byTrip(1L)).thenReturn(root);when(links.tripNo(1L)).thenReturn("TR1");
|
||||
when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));when(links.evidence("PC1")).thenReturn(List.of());
|
||||
trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("IN_TRANSIT");trip.setRecordVersion(3L);
|
||||
when(tripRepo.lockTrip("TR1")).thenReturn(trip);when(tripRepo.findTrip("TR1")).thenReturn(trip);
|
||||
order=new UsTruckOrder();order.setStatus("IN_TRANSIT");when(trucks.lockNo("C1","U1")).thenReturn(order);
|
||||
when(links.commandHash(eq("PC1"),anyString())).thenAnswer(call->commands.get(call.getArgument(1)));
|
||||
doAnswer(call->{commands.put(call.getArgument(1),call.getArgument(2));return null;}).when(links).command(eq("PC1"),anyString(),anyString(),anyString());
|
||||
doAnswer(call->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
|
||||
}
|
||||
/** 无凭证或跨任务文件均不能结束运输,也不能写成功账本。 */
|
||||
@Test void completionRequiresOwnedEvidence(){
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-missing",List.of()))).hasMessageContaining("凭证");
|
||||
when(files.metadata(1L,99L)).thenThrow(new IllegalArgumentException("跨任务凭证"));
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-foreign",List.of(99L)))).hasMessageContaining("跨任务");
|
||||
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());verify(links,never()).command(anyString(),anyString(),anyString(),anyString());
|
||||
}
|
||||
/** 同请求在版本变化后仍恢复原成功结果,不重复推进原单。 */
|
||||
@Test void replayUsesOriginalSuccessfulCommandBeforeCurrentVersion(){
|
||||
var body=command("complete-key",List.of(77L));service.truckCommand("PC1","OP","complete",body);service.truckCommand("PC1","OP","complete",body);
|
||||
assertThat(trip.getStatus()).isEqualTo("COMPLETED");verify(trips,times(1)).recordExecution(any(),eq(3L),eq("LOCAL_TRUCK_COMPLETE"),anyString(),eq("OP"));
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("complete-key",List.of(88L)))).hasMessageContaining("不同内容");
|
||||
}
|
||||
/** 旧版本和未发车原单均不能利用完成接口跳过真实执行。 */
|
||||
@Test void staleVersionAndWrongOriginalStatusCannotComplete(){
|
||||
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",new TransportTaskModels.Command(2L,"stale-key","结束运输",List.of(77L)))).hasMessageContaining("刷新");
|
||||
order.setStatus("DISPATCHED");assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("wrong-state",List.of(77L)))).hasMessageContaining("先确认发车");verifyNoInteractions(files);
|
||||
}
|
||||
/** 下载只允许已完成命令持久化的文件引用,不能凭共享文件 ID 访问。 */
|
||||
@Test void evidenceDownloadRequiresPersistedConfirmation(){
|
||||
assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files);
|
||||
}
|
||||
/** 构造固定版本的测试执行命令。 */
|
||||
private TransportTaskModels.Command command(String key,List<Long> files){return new TransportTaskModels.Command(3L,key,"结束运输",files);}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FleetLooseboardDemandPersistenceTest {
|
||||
/** 真实 SQL 验证同客户关联、唯一来源、取消恢复候选和跨客户隔离。 */
|
||||
@Test void originalDemandLeavesPoolUntilDraftIsCancelled() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_local_looseboard_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),order_no VARCHAR(64),status VARCHAR(32),lastleg_order_id BIGINT,origin VARCHAR(64),destination VARCHAR(64),created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),source_looseboard_order_id BIGINT,status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(customer_id VARCHAR(128),business_type VARCHAR(32),business_order_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(1,'C-1','LB-1','PENDING_ACCEPTANCE',NULL,'LA','Ontario',CURRENT_TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(2,'C-1','OLD','PENDING_ACCEPTANCE',99,'LA','Ontario',CURRENT_TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO ll_fleet_external_booking VALUES(10,'C-1',NULL,'DRAFT'),(11,'C-2',NULL,'DRAFT')");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(FleetLooseboardDemandMapper.class);config.addMapper(FleetPoolOrderMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var session=new SqlSessionTemplate(factory.getObject());
|
||||
var mapper=session.getMapper(FleetLooseboardDemandMapper.class);
|
||||
var links=session.getMapper(FleetPoolOrderMapper.class);
|
||||
assertThat(mapper.pending(Page.of(1,20),"Ontario").getRecords()).extracting("orderNo").containsExactly("LB-1");
|
||||
assertThat(mapper.attach(10,"C-2",1)).isZero();
|
||||
assertThat(mapper.attach(10,"C-1",1)).isEqualTo(1);
|
||||
assertThat(mapper.attach(10,"C-1",2)).isZero();
|
||||
assertThat(mapper.active("C-1",1)).isEqualTo(1);
|
||||
assertThat(mapper.active("C-2",1)).isNull();
|
||||
assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isEqualTo(1);
|
||||
assertThat(mapper.source(10,"C-2")).isNull();
|
||||
assertThat(mapper.source(10,"C-1").getOrderNo()).isEqualTo("LB-1");
|
||||
assertThat(mapper.pending(Page.of(1,20),null).getRecords()).isEmpty();
|
||||
jdbc.update("UPDATE ll_fleet_external_booking SET status='CANCELLED' WHERE id=10");
|
||||
assertThat(mapper.active("C-1",1)).isNull();
|
||||
assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isZero();
|
||||
assertThat(mapper.pending(Page.of(1,20),null).getRecords()).extracting("orderNo").containsExactly("LB-1");
|
||||
assertThat(mapper.sourceNumbers(List.of(10L,11L))).extracting(FleetPoolOrderLink::getBusinessOrderNo).containsExactly("LB-1");
|
||||
jdbc.update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=1 WHERE id=11");
|
||||
assertThat(mapper.source(11,"C-2")).isNull();
|
||||
assertThat(mapper.sourceNumbers(List.of(11L))).isEmpty();
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package com.baiyue.lastleg.fleet.business;
|
||||
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetLooseboardDemandServiceTest {
|
||||
final FleetLooseboardDemandRepository repository=mock(FleetLooseboardDemandRepository.class);
|
||||
final FleetLooseboardDemandService service=new FleetLooseboardDemandService(repository);
|
||||
/** 校验客户隔离、版本、已有匹配与独立履约都阻止重复接单。 */
|
||||
@Test void rejectsMissingStaleMatchedAndPhysicalSources() {
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("不存在");
|
||||
var row=source();when(repository.lock("9","LB-1")).thenReturn(row);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",1L)).hasMessageContaining("变化");
|
||||
assertThatThrownBy(()->service.prepare("9","US_TRUCK","LB-1",0L)).hasMessageContaining("散板");
|
||||
when(repository.active("9",1)).thenReturn(true);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运");
|
||||
when(repository.active("9",1)).thenReturn(false);row.setLastlegOrderId(7L);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("独立履约");
|
||||
verify(repository,never()).attach(anyLong(),anyString(),anyLong());
|
||||
}
|
||||
/** 取消草稿使生效匹配清空,同张原单可重新选车且没有商业复制。 */
|
||||
@Test void allowsRematchAfterDraftCancellationAndKeepsSourceImmutable() {
|
||||
var row=source();when(repository.lock("9","LB-1")).thenReturn(row);
|
||||
when(repository.active("9",1)).thenReturn(true,false);
|
||||
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运");
|
||||
assertThat(service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).isSameAs(row);
|
||||
when(repository.attach(7,"9",1)).thenReturn(1);service.attach(7,row);
|
||||
when(repository.source(7,"9")).thenReturn(row);
|
||||
service.validateEdit(7,"9","LOCAL_LOOSEBOARD",null);
|
||||
assertThatThrownBy(()->service.validateEdit(7,"9","US_TRUCK",null)).hasMessageContaining("不能替换");
|
||||
assertThatThrownBy(()->service.validateEdit(7,"9","LOCAL_LOOSEBOARD","LB-2")).hasMessageContaining("不能替换");
|
||||
}
|
||||
/** 构造待匹配原单。 */
|
||||
private LocalLooseboardOrder source() {
|
||||
var row=new LocalLooseboardOrder();row.setId(1L);row.setCustomerId("9");row.setOrderNo("LB-1");row.setVersion(0L);row.setStatus("PENDING_ACCEPTANCE");return row;
|
||||
}
|
||||
}
|
||||
-19
@@ -25,13 +25,11 @@ class FleetPoolOrderBridgeTest {
|
||||
FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class);
|
||||
FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class);
|
||||
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
|
||||
FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class);
|
||||
FleetPoolOrderBridge service;
|
||||
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
|
||||
@BeforeEach void setup(){
|
||||
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
|
||||
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands);
|
||||
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
|
||||
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
|
||||
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
|
||||
@@ -47,23 +45,6 @@ class FleetPoolOrderBridgeTest {
|
||||
BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");}
|
||||
void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");}
|
||||
|
||||
/** 原单匹配后全链复用客户商业单,实收到签收均不另建商业单。 */
|
||||
@Test void matchedSourceRemainsTheSameCommercialOrderThroughDelivery() {
|
||||
looseOrder=new LocalLooseboardOrder();looseOrder.setId(31L);looseOrder.setCustomerId("9");looseOrder.setOrderNo("LB-ORIGINAL");looseOrder.setVersion(4L);looseOrder.setStatus("PENDING_ACCEPTANCE");
|
||||
looseOrder.setOrigin("客户原始提货地址");looseOrder.setDestinationContactName("原始收货人");
|
||||
when(demands.source(7,"9")).thenReturn(looseOrder);when(loose.lockNo("9","LB-ORIGINAL")).thenAnswer(a->looseOrder);
|
||||
var original=looseOrder;
|
||||
var confirmed=service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
assertThat(confirmed.businessOrderNo()).isEqualTo("LB-ORIGINAL");assertThat(row.getBusinessOrderId()).isEqualTo(31L);
|
||||
assertThat(service.confirm(input("LOCAL_LOOSEBOARD"),"op")).isEqualTo(confirmed);
|
||||
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");
|
||||
service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
|
||||
service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());
|
||||
assertThat(looseOrder).isSameAs(original);assertThat(original.getStatus()).isEqualTo("COMPLETED");
|
||||
assertThat(original.getLastlegOrderId()).isEqualTo(77L);assertThat(original.getDestinationContactName()).isEqualTo("原始收货人");
|
||||
verify(loose,never()).insert(any(LocalLooseboardOrder.class));
|
||||
}
|
||||
|
||||
@Test void materializesTheOriginalAppointmentAfterActualIncomingAssociationAndNeverOnReplay() {
|
||||
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
incoming();
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.controller.OutsourcedBusinessController;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** HTTP公开JSON精简及拒绝行为,防止仅页面隐藏不支持字段。 */
|
||||
class OutsourcedBusinessContractTest {
|
||||
/** 即使全局允许未知字段,本域仍明确拒绝油耗、里程及客户端归属。 */
|
||||
@Test void unsupportedOperationsAreRejectedEvenWithLenientGlobalJackson() throws Exception {
|
||||
var json=new ObjectMapper().findAndRegisterModules().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
String body="{\"recordVersion\":\"0\",\"idempotencyKey\":\"test-original-key\",\"operations\":{\"money\":null,\"arrivals\":[]}}";
|
||||
assertNotNull(json.readValue(body,OutsourcedBusinessModels.SaveOperations.class));
|
||||
for(String field:List.of("actualDistanceMiles","fuelConsumptionLitresPer100Km","actualFuelLitres","ownership","scope")) {
|
||||
String extra=body.replace("\"arrivals\":[]","\"arrivals\":[],\""+field+"\":null");
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue(extra,OutsourcedBusinessModels.SaveOperations.class),field);
|
||||
}
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue(body.replace("\"recordVersion\":\"0\"","\"recordVersion\":\"0\",\"scope\":\"OWNED\""),OutsourcedBusinessModels.SaveOperations.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("{\"recordVersion\":\"0\",\"idempotencyKey\":\"bind-original\",\"fileId\":\"9\",\"ownership\":\"OWNED\"}",OutsourcedBusinessModels.BindPhoto.class));
|
||||
}
|
||||
/** 外配输出没有油耗字段和摘要,自有原JSON字段继续存在。 */
|
||||
@Test void outsourcedSerializationIsLeanAndOwnedPublicShapeRemains() throws Exception {
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
var output=json.valueToTree(new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),List.of()));
|
||||
assertEquals(2,output.size());assertTrue(output.has("money"));assertTrue(output.has("arrivals"));
|
||||
assertFalse(output.has("actualDistanceMiles"));assertFalse(output.has("fuelConsumptionLitresPer100Km"));
|
||||
assertEquals(1,json.valueToTree(new OutsourcedBusinessModels.PhotoSummary(5)).size());
|
||||
var external=json.valueToTree(new OutsourcedBusinessModels.Vehicle(null,"TEST-EXT","DISPATCHED",null));
|
||||
assertTrue(external.get("vehicleId").isNull());assertTrue(external.get("carrierName").isNull());assertEquals(4,external.size());
|
||||
var owned=json.valueToTree(new OwnedBusinessModels.Operations(null,null,null,List.of()));
|
||||
assertTrue(owned.has("actualDistanceMiles"));assertTrue(owned.has("fuelConsumptionLitresPer100Km"));
|
||||
assertEquals(3,json.valueToTree(new OwnedBusinessModels.Vehicle("7","TEST-OWN","OWNED")).size());
|
||||
}
|
||||
/** 油耗框不是外配合法URL枚举,不能先上传再在页面隐藏。 */
|
||||
@Test void onlyFiveLoadingPhotoSlotsCanDeserialize() throws Exception {
|
||||
var json=new ObjectMapper();assertEquals(5,OutsourcedBusinessModels.PhotoSlot.values().length);
|
||||
for(var slot:OutsourcedBusinessModels.PhotoSlot.values())assertEquals(slot,json.readValue("\""+slot.name()+"\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_START\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_END\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
}
|
||||
/** 八类HTTP操作各用外配真实权限,不将外配权限伪装为自有。 */
|
||||
@Test void controllerAlwaysRequiresTheActualOutsourcedPermission() {
|
||||
int count=0;
|
||||
for(var method:OutsourcedBusinessController.class.getDeclaredMethods()) {
|
||||
var authorization=method.getAnnotation(PreAuthorize.class);if(authorization==null)continue;count++;
|
||||
String action=switch(method.getName()){case "save"->"edit";case "upload","bind"->"upload";default->"list";};
|
||||
assertEquals("hasAuthority('platform:fleet:outsourced-business:"+action+"')",authorization.value());
|
||||
assertTrue(Arrays.stream(method.getParameterTypes()).noneMatch(p->p.getSimpleName().equals("OwnedBusinessScope")));
|
||||
}
|
||||
assertEquals(8,count);
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
|
||||
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.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 复用既有四表测试装配,实际Mapper、事务、权限与原键一并验收。 */
|
||||
class OwnedBusinessScopePersistenceTest {
|
||||
final OwnedBusinessPersistenceTest baseline=new OwnedBusinessPersistenceTest();
|
||||
OwnedBusinessQueryService queries;
|
||||
OwnedBusinessRecordService records;
|
||||
OwnedBusinessPhotoService photos;
|
||||
OwnedBusinessFileGateway files;
|
||||
OutsourcedBusinessService outsourced;
|
||||
final Map<Long,OwnedBusinessFileGateway.Evidence> uploaded=new HashMap<>();
|
||||
final byte[] png={(byte)137,80,78,71,13,10,26,10,0};
|
||||
/** 只调用原测试的建库装配,不继承或重复运行其@Test。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
baseline.setup();var reader=mock(DispatchDetailReader.class);
|
||||
when(reader.detail(any(),anyString())).thenAnswer(call->{
|
||||
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
|
||||
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
|
||||
});
|
||||
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
|
||||
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
|
||||
records=tx(new OwnedBusinessRecordService(baseline.repository,queries,baseline.projection,commands,baseline.codec));
|
||||
files=mock(OwnedBusinessFileGateway.class);var sequence=new AtomicLong(900);
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{
|
||||
long id=sequence.incrementAndGet();var evidence=new OwnedBusinessFileGateway.Evidence(id,"TEST-"+id+".png","image/png",png.length,"2026-10-20T08:00:00Z");uploaded.put(id,evidence);return evidence;
|
||||
});
|
||||
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->uploaded.get(call.<Long>getArgument(2)));
|
||||
when(files.content(anyLong(),any(),anyLong())).thenReturn(png);
|
||||
photos=tx(new OwnedBusinessPhotoService(queries,records,baseline.repository,commands,baseline.projection,baseline.codec,files));
|
||||
outsourced=new OutsourcedBusinessService(queries,records,photos,baseline.projection,baseline.codec);
|
||||
insert(2,"MASTER","AFTER_LOADING","TEST原承运商");insert(3,"EXTERNAL","DELIVERING",null);insert(4,"MASTER","DELIVERED","TEST另一承运商");
|
||||
}
|
||||
/** 数据库仅在本机隔离H2,用后关闭。 */
|
||||
@AfterEach void close(){baseline.closeDatabase();}
|
||||
/** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */
|
||||
@SuppressWarnings("unchecked") private <T>T tx(T target) {
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(baseline.manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
|
||||
}
|
||||
/** 两类外配身份固定原快照,实际托盘号和两个客户仍逐项保留。 */
|
||||
private void insert(long id,String mode,String stage,String carrier) {
|
||||
var original=OwnedBusinessFixtures.source();
|
||||
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of());
|
||||
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver()));
|
||||
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
|
||||
baseline.jdbc.update("""
|
||||
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
|
||||
loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1
|
||||
""",id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id);
|
||||
baseline.jdbc.update("""
|
||||
INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,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=1
|
||||
""",id*100,id,"PAL-"+(31+id),31+id);
|
||||
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId());
|
||||
ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L);
|
||||
ownership.setLoadingCompletionHash(original.getLoadingCompletionHash());ownership.setLoadingCompletedAt(original.getLoadingCompletedAt());ownership.setOwnershipCapturedAt(original.getLoadingCompletedAt());baseline.repository.capture(ownership);
|
||||
baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt());
|
||||
if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id);
|
||||
if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id);
|
||||
}
|
||||
/** 外配真实三权限,与自有集合完全不相交。 */
|
||||
private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));}
|
||||
/** 请求只包含金额和本车目的地到达,版本与原键逐次显式提供。 */
|
||||
private OutsourcedBusinessModels.SaveOperations request(String version,String key,OwnedBusinessModels.Money money,List<OwnedBusinessModels.Arrival> arrivals) {
|
||||
return new OutsourcedBusinessModels.SaveOperations(version,key,new OutsourcedBusinessModels.Operations(money,arrivals),"本机TEST承运登记");
|
||||
}
|
||||
/** 真实SQL统计跨页且按归属、阶段、日期、车牌和目的仓共同过滤。 */
|
||||
@Test void fullSummaryAndFilterUseOneFrozenScope() {
|
||||
var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,"9");var result=outsourced.list(actor(),query);
|
||||
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(new OwnedBusinessModels.Summary(3,1,1,1),result.summary());
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"TEST-OUT-3",null,"9")).total());
|
||||
assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"ONT8",null,"9")).total());
|
||||
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,"other")).total());
|
||||
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total());
|
||||
for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total());
|
||||
}
|
||||
/** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */
|
||||
@Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() {
|
||||
var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3");
|
||||
assertEquals("7",master.row().vehicle().vehicleId());assertEquals("TEST原承运商",master.row().vehicle().carrierName());
|
||||
assertNull(external.row().vehicle().vehicleId());assertNull(external.row().vehicle().carrierName());
|
||||
assertEquals("DISPATCHED",external.row().vehicle().ownership());assertEquals("0",master.row().recordVersion());
|
||||
assertEquals(2,master.cargo().size());assertEquals(1,master.row().actualPalletCount());assertTrue(master.row().actions().edit().allowed());
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
baseline.jdbc.update("DELETE FROM ll_fleet_owned_business_ownership WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));assertEquals(2,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,null)).total());assertNull(baseline.repository.ownership(2));
|
||||
}
|
||||
/** 实际服务权限与原归属双向隔离,不允许靠共享四表越权。 */
|
||||
@Test void bothPermissionAndDispatchScopeAreRequiredOnReadWriteAndCommand() {
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
|
||||
assertThrows(ResponseStatusException.class,()->queries.detail(actor(),"DS-OWN-ONE"));
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OWN-ONE"));
|
||||
assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OWN-ONE",request("0","foreign-scope-save",null,List.of())));
|
||||
assertThrows(BizException.class,()->outsourced.command(actor(),"DS-OWN-ONE","foreign-scope-key"));
|
||||
assertThrows(BizException.class,()->photos.command(OwnedBusinessFixtures.actor(),"DS-OUT-2","foreign-scope-key"));
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.save(OwnedBusinessFixtures.actor(),"DS-OUT-2",request("0","wrong-permission-write",null,List.of())));
|
||||
var all=new HashSet<>(actor().permissions());all.addAll(OwnedBusinessFixtures.actor().permissions());
|
||||
var both=new OwnedBusinessModels.Actor("both-scopes","TEST双权限操作员",all);
|
||||
assertThrows(BizException.class,()->outsourced.detail(both,"DS-OWN-ONE"));assertThrows(BizException.class,()->queries.detail(both,"DS-OUT-2"));
|
||||
assertThrows(BizException.class,()->records.save(both,"DS-OUT-2",new OwnedBusinessModels.SaveOperations("0","wrong-owned-write",new OwnedBusinessModels.Operations(null,null,null,List.of()),null)));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
}
|
||||
/** 多客户同一物理托盘只除一次,逐目的地登记、零值与NULL清空都走真实CAS。 */
|
||||
@Test void samePhysicalPalletMultipleDestinationsAndMoneyClearUseRealCas() {
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE id=212");
|
||||
var arrivals=List.of(new OwnedBusinessModels.Arrival("FBA:ONT8","2026-10-20T08:10:00Z"),new OwnedBusinessModels.Arrival("FBA:LAX9","2026-10-20T08:20:00Z"));
|
||||
var body=request("0","carrier-amount-original",new OwnedBusinessModels.Money("CNY","60"),arrivals);
|
||||
var first=outsourced.save(actor(),"DS-OUT-2",body);
|
||||
assertEquals("60.00",first.row().unitPrice());assertEquals(2,first.row().destinations().size());assertTrue(first.row().destinations().stream().allMatch(d->d.actualArrivedAt()!=null));
|
||||
assertEquals(first,outsourced.save(actor(),"DS-OUT-2",body));
|
||||
assertEquals(first,outsourced.command(actor(),"DS-OUT-2",body.idempotencyKey()).result());
|
||||
var stale=assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","carrier-amount-stale",null,List.of())));assertEquals(ResultCode.CONFLICT,stale.getResultCode());
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0",body.idempotencyKey(),new OwnedBusinessModels.Money("USD","0"),arrivals)));
|
||||
var zero=outsourced.save(actor(),"DS-OUT-2",request("1","carrier-amount-zero",new OwnedBusinessModels.Money("USD","0"),arrivals));
|
||||
assertEquals("0.00",zero.row().unitPrice());assertEquals("USD",zero.row().operations().money().currency());
|
||||
var cleared=outsourced.save(actor(),"DS-OUT-2",request("2","carrier-amount-clear",null,List.of()));
|
||||
assertNull(cleared.row().operations().money());assertNull(cleared.row().unitPrice());assertTrue(cleared.row().destinations().stream().allMatch(d->d.actualArrivedAt()==null));
|
||||
assertNull(baseline.repository.record(2).getCurrency());assertNull(baseline.repository.record(2).getActualDistanceMiles());assertNull(baseline.repository.record(2).getFuelConsumptionLitresPer100Km());
|
||||
assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
|
||||
}
|
||||
/** 油耗及里程在共享服务再次拒绝,不能绕过外配DTO直接调用内部入口。 */
|
||||
@Test void unsupportedFuelAndForeignDestinationAreRejectedBeforeAnyWrite() {
|
||||
var body=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-write",new OwnedBusinessModels.Operations(null,"1",null,List.of()),null);
|
||||
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",body,OwnedBusinessScope.OUTSOURCED));
|
||||
var fuel=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-rate",new OwnedBusinessModels.Operations(null,null,"1",List.of()),null);
|
||||
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",fuel,OwnedBusinessScope.OUTSOURCED));
|
||||
assertThrows(BizException.class,()->photos.upload(actor(),"DS-OUT-2",OwnedBusinessModels.PhotoSlot.FUEL_START,"illegal-fuel-photo",new MockMultipartFile("file",png),OwnedBusinessScope.OUTSOURCED));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","illegal-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:OTHER",null)))));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","duplicate-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:ONT8",null),new OwnedBusinessModels.Arrival("FBA:ONT8",null)))));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));verifyNoInteractions(files);
|
||||
}
|
||||
/** 五框上传/绑定/原键恢复及历史读取保持同车原引用,异账号不能复用。 */
|
||||
@Test void fivePhotosReplayHistoryAndActorIsolationDoNotChangeDispatch() {
|
||||
String version="0",firstFile=null;int index=0;
|
||||
for(var slot:OutsourcedBusinessModels.PhotoSlot.values()) {
|
||||
var file=new MockMultipartFile("file","TEST.png","image/png",png);String uploadKey="out-upload-"+slot;
|
||||
var photo=outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file);assertNull(photo.savedAt());
|
||||
assertEquals(photo,outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file));
|
||||
assertEquals(version,outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
|
||||
var binding=new OutsourcedBusinessModels.BindPhoto(version,"out-bind-"+slot,photo.fileId());
|
||||
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,binding);assertEquals(bound,outsourced.bind(actor(),"DS-OUT-2",slot,binding));version=bound.row().recordVersion();
|
||||
if(index++==0)firstFile=photo.fileId();assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",slot,Long.parseLong(photo.fileId()),null).content());
|
||||
}
|
||||
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
|
||||
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
||||
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId()));
|
||||
final long oldId=Long.parseLong(firstFile);
|
||||
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
|
||||
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
|
||||
var history=outsourced.history(actor(),"DS-OUT-2",1,200);assertEquals(7,history.total());
|
||||
assertTrue(history.records().stream().anyMatch(h->h.eventSource().equals("DISPATCH") && h.recordVersion()==null && h.dispatchRecordVersion().equals("8") && h.operations()==null));
|
||||
assertTrue(history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).allMatch(h->h.dispatchRecordVersion()==null && h.reason().startsWith("保存")));
|
||||
var other=new OwnedBusinessModels.Actor("other","TEST另一外配账号",actor().permissions());
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.command(other,"DS-OUT-2","out-upload-LOADING_SCENE"));
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.upload(other,"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-upload-LOADING_SCENE",new MockMultipartFile("file","TEST.png","image/png",png)));
|
||||
verify(files,times(6)).upload(anyLong(),any(),anyString(),any());assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
|
||||
}
|
||||
/** File结果未知只保存原意图,任何同键重放不得再次写File。 */
|
||||
@Test void unknownUploadIsRecoverableButNeverAutomaticallyRetried() {
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("本机模拟连接中断"));
|
||||
var file=new MockMultipartFile("file","TEST.png","image/png",png);
|
||||
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
|
||||
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
|
||||
var command=outsourced.command(actor(),"DS-OUT-2","out-upload-unknown");assertEquals("UNKNOWN_RESULT",command.state());assertFalse(command.retryAllowed());assertNull(command.result());
|
||||
assertEquals("OUTSOURCED_UPLOAD_UNKNOWN_RESULT",command.errorCode());verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 原装车ID、模式、hash、时间任何一项不一致都不能进入外配详情。 */
|
||||
@Test void frozenIdentityHashAndTimeCannotBeSubstituted() {
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=999 WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=7,loading_completion_hash='different' WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash='actual-load',loading_completed_at=? WHERE dispatch_id=2",OwnedBusinessFixtures.source().getLoadingCompletedAt().plusSeconds(1));
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
String external=baseline.repository.source("DS-OUT-3").getResourcesJson().replace("\"vehicleId\":null","\"vehicleId\":99");
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=3",external);
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3"));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
}
|
||||
/** 承运金额按两实托HALF_UP两位,零实托不能伪造单价或使用来源条数替代。 */
|
||||
@Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() {
|
||||
String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},");
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load);
|
||||
baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211");
|
||||
var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of()));
|
||||
assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice());
|
||||
var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class);
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",baseline.codec.write(new DispatchContracts.ConfirmedLoad(confirmation.manifestHash(),List.of(),confirmation.operatorId(),confirmation.confirmedAt())));
|
||||
var empty=outsourced.detail(actor(),"DS-OUT-2");assertEquals(0,empty.row().actualPalletCount());assertNull(empty.row().unitPrice());assertFalse(empty.row().sourceIssues().isEmpty());
|
||||
}
|
||||
/** 元数据暂不可读时原绑定体和fileId不丢失,恢复后不重复上传。 */
|
||||
@Test void failedBindRetainsOriginalFileAndKeyForExplicitRecovery() {
|
||||
var slot=OutsourcedBusinessModels.PhotoSlot.LOADING_LIST;
|
||||
var uploadedPhoto=outsourced.upload(actor(),"DS-OUT-2",slot,"out-bind-failure-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
||||
long fileId=Long.parseLong(uploadedPhoto.fileId());
|
||||
when(files.metadata(2,OwnedBusinessModels.PhotoSlot.LOADING_LIST,fileId)).thenThrow(new BizException(ResultCode.CONFLICT,"本机模拟元数据暂不可读")).thenReturn(uploaded.get(fileId));
|
||||
var body=new OutsourcedBusinessModels.BindPhoto("0","out-bind-failure-key",uploadedPhoto.fileId());
|
||||
assertThrows(BizException.class,()->outsourced.bind(actor(),"DS-OUT-2",slot,body));
|
||||
assertEquals("0",outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
|
||||
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,body);assertEquals(uploadedPhoto.fileId(),bound.photos().get(0).fileId());
|
||||
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 完整摘要严格匹配,同前缀末位、大小写和尾空格均不能误命中。 */
|
||||
@Test void queryHashComparesCompleteValueRatherThanDefaultBinaryWidth() {
|
||||
String prefix="a".repeat(63),exact=prefix+"1";
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_completion_hash=? WHERE id=1",exact);
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
|
||||
var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null);
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
for(String different:List.of(prefix+"2","A"+exact.substring(1),prefix+" ")) {
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",different);
|
||||
var result=queries.list(OwnedBusinessFixtures.actor(),query);assertEquals(0,result.total());assertTrue(result.records().isEmpty());
|
||||
}
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
}
|
||||
/** 历史投影统一字符比较规则,完整保留中文、原尾空格、动作和原操作账号。 */
|
||||
@Test void historyCharacterProjectionPreservesOriginalTextAndSnapshot() {
|
||||
// 原测试装配的原生历史采用较短替身列,这里只在隔离H2对齐实际原表上限。
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN operator_id VARCHAR(128)");
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN reason VARCHAR(2000)");
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN action VARCHAR(40)");
|
||||
String nativeReason="原排车完整中文历史🌏"+"中".repeat(1200)+" ",operationReason="整车承运登记完整中文说明🌏 ",operator="o".repeat(127)+"X",nativeAction="A".repeat(39)+"X";
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_history SET reason=?,operator_id=?,action=? WHERE dispatch_id=2",nativeReason,operator,nativeAction);
|
||||
var saved=outsourced.save(actor(),"DS-OUT-2",new OutsourcedBusinessModels.SaveOperations("0","history-character-original",
|
||||
new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("CNY","30"),List.of()),operationReason));
|
||||
var history=outsourced.history(actor(),"DS-OUT-2",1,20);assertEquals(2,history.total());
|
||||
var original=history.records().stream().filter(h->h.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
|
||||
var operations=history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
|
||||
assertEquals(nativeReason,original.reason());assertEquals(nativeAction,original.action());
|
||||
assertEquals(operationReason,operations.reason());assertEquals("SAVE_OPERATIONS",operations.action());
|
||||
assertEquals(saved.row().operations(),operations.operations());assertEquals("TEST外配操作员",operations.operatorName());
|
||||
var raw=baseline.repository.history(2,0,20).stream().filter(h->h.getEventSource().equals("DISPATCH")).findFirst().orElseThrow();
|
||||
assertEquals(operator,raw.getOperatorId());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -47,7 +47,7 @@ class FleetTripApiContractTest {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assertThat(count).isEqualTo(47);
|
||||
assertThat(count).isEqualTo(46);
|
||||
verifyRefs(document, document);
|
||||
verifyRefs(preparation, preparation);
|
||||
verifyRefs(pool, pool);
|
||||
|
||||
+9
-22
@@ -12,20 +12,23 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTripAuthorizationTest {
|
||||
@Test void calendarRequiresTripReadPermissionThroughMethodSecurity() {
|
||||
/** 日历按现行排车只读权限放行,预约和旧车次权限不能替代。 */
|
||||
@Test void calendarRequiresDispatchReadPermissionThroughMethodSecurity() {
|
||||
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller = context.getBean(PlatformFleetTripController.class);
|
||||
var calendar = context.getBean(FleetTripCalendarService.class);
|
||||
var from = java.time.Instant.parse("2026-10-01T00:00:00Z");
|
||||
var to = from.plusSeconds(86400);
|
||||
var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L,
|
||||
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:fleet:trip:list"));
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
user, "unused", List.of(new SimpleGrantedAuthority("platform:appointment:list"))));
|
||||
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
|
||||
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:overseas-warehouse:dispatch:list"));
|
||||
for (var permission : List.of("platform:appointment:list", "platform:fleet:trip:list")) {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
user, "unused", List.of(new SimpleGrantedAuthority(permission))));
|
||||
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
verifyNoInteractions(calendar);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
user, "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
|
||||
user, "unused", List.of(new SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:list"))));
|
||||
controller.calendar(user, from, to);
|
||||
verify(calendar).calendar(from, to);
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
@@ -78,18 +81,6 @@ class FleetTripAuthorizationTest {
|
||||
assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
} finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
/** 需求含客户原单资料,仅列表权限不能读取待匹配需求。 */
|
||||
@Test void pendingDemandRequiresManageOrListAndCreate() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(PlatformFleetCapacityOfferController.class);
|
||||
var service=context.getBean(FleetCapacityOfferService.class);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
|
||||
assertThatThrownBy(()->controller.pendingDemands(null)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoInteractions(service);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST),new SimpleGrantedAuthority("platform:fleet:capacity-offer:inquiry-create"))));
|
||||
controller.pendingDemands(null);verify(service).pendingDemands(null);
|
||||
} finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
@Test void externalArrangeRequiresCurrentCreateAndAllIndependentPermissions() {
|
||||
var required=List.of("platform:fleet:capacity-offer:manage","platform:fleet:trip:booking-edit","platform:fleet:trip:edit","platform:overseas-warehouse:dispatch:create");
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
@@ -109,10 +100,6 @@ class FleetTripAuthorizationTest {
|
||||
}
|
||||
@Configuration @EnableMethodSecurity
|
||||
static class Config {
|
||||
/** 原单需求依赖使用替身,权限测试不访问数据库。 */
|
||||
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); }
|
||||
/** 桥接器的原单查询使用仓储替身。 */
|
||||
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); }
|
||||
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
|
||||
@Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); }
|
||||
@Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); }
|
||||
|
||||
+7
-6
@@ -64,9 +64,6 @@ class LocalLooseboardServiceTest {
|
||||
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
|
||||
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
// 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。
|
||||
var legacyTail = new LastlegOrder(); legacyTail.setId(99L); legacyTail.setCustomerId("100");
|
||||
org.mockito.Mockito.lenient().when(lastlegOrders.selectById(99L)).thenReturn(legacyTail);
|
||||
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
|
||||
.thenReturn("LB-WH-20260901-000001");
|
||||
org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString()))
|
||||
@@ -97,8 +94,12 @@ class LocalLooseboardServiceTest {
|
||||
assertEquals("家居用品", detail.summary().productName());
|
||||
assertEquals(3, detail.summary().estimatedPalletCount());
|
||||
assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt());
|
||||
org.junit.jupiter.api.Assertions.assertNull(saved.get().getLastlegOrderId());
|
||||
verify(lastlegOrders, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.<LastlegOrder>any());
|
||||
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
|
||||
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
|
||||
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
|
||||
&& value.getInboundCode() == null
|
||||
&& value.getPalletCount() == 0
|
||||
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
|
||||
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
|
||||
"CREATE".equals(value.getOperationType())));
|
||||
}
|
||||
@@ -207,7 +208,7 @@ class LocalLooseboardServiceTest {
|
||||
|
||||
private LocalLooseboardOrder order(String status, long version) {
|
||||
LocalLooseboardOrder order = new LocalLooseboardOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); order.setLastlegOrderId(99L);
|
||||
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
|
||||
order.setPalletRequired("YES");
|
||||
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
|
||||
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
|
||||
|
||||
@@ -57,19 +57,9 @@
|
||||
],
|
||||
"reviewedClassDependencies": [
|
||||
{
|
||||
"source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService",
|
||||
"target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository",
|
||||
"reason": "本土卡车接入现有排车的本地事务编排:创建和执行先锁原单,防止普通原单命令并发推进;仅允许此编排器通过Repository访问,不批准Mapper或跨服务SQL。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService",
|
||||
"target": "com.baiyue.lastleg.trucking.entity.UsTruckOrder",
|
||||
"reason": "同一锁单事务核对本土卡车真实起终点、实收托数和申报载重容积,不能用浏览器载荷替代原单;不构造库存或向API暴露原单写模型。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.dispatch.transport.TransportTaskProjection",
|
||||
"target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository",
|
||||
"reason": "同步运输事件与本土卡车状态必须同事务提交和回滚,旧内部执行入口也要同步原单并追加原单历史;取消释放有效原单关联,不合并客户和仓库归属。"
|
||||
"source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels",
|
||||
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
|
||||
"reason": "外配装车后视图组合原排车公开只读详情,复用同一履约事实,不读取实体或写主单。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels",
|
||||
@@ -370,26 +360,6 @@
|
||||
"source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter",
|
||||
"target": "com.baiyue.lastleg.dispatch.port.DispatchTypes",
|
||||
"reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandMapper",
|
||||
"target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository",
|
||||
"target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository",
|
||||
"target": "com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService",
|
||||
"target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder",
|
||||
"reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单"
|
||||
}
|
||||
],
|
||||
"applicationDependencyRule": {
|
||||
|
||||
@@ -1743,7 +1743,7 @@ components:
|
||||
name: mainStatus
|
||||
in: query
|
||||
required: false
|
||||
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
|
||||
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 为实际开船后待上传清关资料;清关资料上传后进入 WAIT_UNLOAD,持续到实际卸柜完成。实际开船统一读取手动或拉取的实际离港节点。
|
||||
schema:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
`local-dispatch-transport-acceptance.mjs`:仅本机 LOCAL 的现有排车运输任务 HTTP 验收。显式 `node --env-file=<本机凭据文件>` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME/PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`;拒绝远端网关。创建3张 TEST 仓储转运单、1张本土卡车原单、5张排车(完成/取消/重新安排),以及本轮专用调度/PDA账号、角色、车辆司机;缺失的正式排车权限仅补资源并授予专用角色,不修改既有角色。经正式网关验证原单审核、跨客户取货、资源冲突、凭证隔离、取消重排、发车完成回写、独立仓库清点和车辆司机释放。合成 PNG 只验文件链路,不代表实际运输或有效证照。默认报告 `reports/local-dispatch-transport.json`,可用 `TRANSPORT_ACCEPTANCE_OUTPUT` 指定;每步保留请求键、脱敏意图、错误码和关联标识,失败停止且保留数据。确认已知失败结果后显式 `TRANSPORT_ACCEPTANCE_RESUME=true` 复用同一报告继续;网络结果未知必须先核对,不自动清理或删除,不使用业务 SQL。已成功批次禁止重复运行;不能替代远端部署或人工截图验收。
|
||||
|
||||
`local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。
|
||||
|
||||
`local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。
|
||||
|
||||
@@ -562,7 +562,7 @@ Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin
|
||||
Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI'
|
||||
Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量'
|
||||
Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口'
|
||||
Assert-Contains $clientAgents 'customerFeeConfirmationRequired=true' 'Client 费用待确认入口'
|
||||
Assert-Contains $clientAgents '所有订单行均可进入费用确认弹框' 'Client 费用确认入口'
|
||||
Assert-Contains $clientOrdersView 'dialog-only' 'Client 费用确认弹框实现'
|
||||
Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java'
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
|
||||
// 仅针对显式指定的本机统一网关;业务事实全部使用正式 HTTP 创建。
|
||||
const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '')
|
||||
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('必须显式指定本机 Gateway')
|
||||
if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD || !process.env.LOCAL_TRANSFER_PDA_PASSWORD) throw new Error('缺少显式验收凭据')
|
||||
const output = resolve(process.env.TRANSPORT_ACCEPTANCE_OUTPUT || 'reports/local-dispatch-transport.json')
|
||||
const report = process.env.TRANSPORT_ACCEPTANCE_RESUME ? JSON.parse(await readFile(output, 'utf8')) : { marker: `DT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`, evidence: 'SYNTHETIC_TEST_PNG_NOT_REAL_PHYSICAL_TRANSPORT', checks: [], documents: {}, requests: {}, intents: [] }
|
||||
if (report.passed) throw new Error('本批次已通过,请直接查看报告')
|
||||
const marker = report.marker, base = '/api/platform/v1/last-leg/dispatches/transport-tasks', transfers = '/api/platform/v1/last-leg/warehouse-transfers', trucks = '/api/platform/v1/last-leg/us-truck-orders'
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
||||
let admin, pda
|
||||
async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) }
|
||||
function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw new Error(label) }
|
||||
function image(name) { const body = new FormData(); body.append('file', new Blob([png], { type: 'image/png' }), `${marker}-${name}-TEST.png`); return body }
|
||||
async function api(token, method, path, body, expected = '200') {
|
||||
const form = body instanceof FormData
|
||||
if (method !== 'GET' && !path.startsWith('/api/auth/')) { report.intents.push({ method, path, idempotencyKey: body?.idempotencyKey, hash: form ? undefined : createHash('sha256').update(JSON.stringify(body ?? null)).digest('hex') }); await save() }
|
||||
const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) })
|
||||
const value = await response.json().catch(() => null), code = String(value?.code)
|
||||
const passed = response.status === 200 && code === expected
|
||||
report.checks.push({ method, path, code, http: response.status, expected, passed, message: code === '200' ? undefined : value?.message, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }); await save()
|
||||
if (!passed) throw new Error(`${method} ${path}: ${response.status}/${code} ${value?.message || ''}`)
|
||||
return expected === '200' ? value.data : value
|
||||
}
|
||||
const call = (method, path, body, expected) => api(admin, method, path, body, expected)
|
||||
const request = (key, body) => report.requests[key] ||= { ...body, idempotencyKey: randomUUID() }
|
||||
const read = number => call('GET', `${base}/${number}`)
|
||||
const command = detail => ({ recordVersion: detail.execution.trip.recordVersion, idempotencyKey: randomUUID(), reason: `${marker} 本机运输流程验收` })
|
||||
async function act(detail, suffix, extra = {}, expected) { return call('POST', `${base}/${detail.dispatchNo}/${suffix}`, { ...command(detail), ...extra }, expected) }
|
||||
async function transfer(customer, warehouse, key) {
|
||||
if (report.documents[key]) return report.documents[key]
|
||||
const body = request(key, { operatingWarehouseId: warehouse.id, destinationType: 'PRIVATE', privateDestinationAddress: '100 TEST Distribution Road, Ontario, CA', privateDestinationPostalCode: '91761', privateDestinationContactName: '本机验收收货员', privateDestinationContactPhone: '0000000000', deliveryAddress: `1580 TEST Pickup Ave, Ontario, CA (${marker}-${key})`, pickupContactName: '本机验收取货员', pickupContactPhone: '0000000000', packageCount: 3, volumeCbm: 0, deliveryMethod: 'WAREHOUSE_PICKUP', estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), estimatedShipAt: new Date(Date.now()+172800000).toISOString().slice(0,19), remark: marker, needRelabel: false, items: [{ skuCode: `${marker}-${key}`, productNameZh: '本机验收收纳盒', quantity: 24, packageCount: 3, shippingMark: `${marker}-${key}` }] })
|
||||
await save()
|
||||
let detail = await call('POST', `${transfers}?customerId=${customer.id}`, body)
|
||||
detail = await call('POST', `${transfers}/${detail.transferNo}/review?customerId=${customer.id}`, { decision: 'APPROVE', remark: marker })
|
||||
report.documents[key] = { customerId: customer.id, transferNo: detail.transferNo, packageCount: detail.packageCount, cartonMarks: detail.cartons.map(row=>row.cartonMark) }; await save(); return report.documents[key]
|
||||
}
|
||||
async function pickup(sources, key) {
|
||||
if (report.documents[key]) return read(report.documents[key].dispatchNo)
|
||||
const body = request(key, { sources: sources.map(({customerId,transferNo})=>({customerId,transferNo})), plannedDepartureAt: new Date(Date.now()-300000).toISOString(), expectedDriverEndAt: new Date(Date.now()+86400000).toISOString(), remark: marker })
|
||||
await save(); const detail = await call('POST', `${base}/pickup`, body)
|
||||
report.documents[key] = { dispatchNo: detail.dispatchNo, sources: body.sources }; await save()
|
||||
const replay = await call('POST', `${base}/pickup`, body); check(replay.dispatchNo === detail.dispatchNo, '到仓取货重放返回同一排车号'); return detail
|
||||
}
|
||||
async function resources(warehouse) {
|
||||
if (report.resources) return report.resources
|
||||
const driverBase = '/api/platform/v1/last-leg/fleet/drivers'
|
||||
let driver = await call('POST', driverBase, { name: `${marker}专用司机`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now()+365*86400000).toISOString().slice(0,10), remark: `${marker} TEST licence image`, profile: { warehouseId: warehouse.id, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario TEST' } })
|
||||
driver = await call('POST', `${driverBase}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image('licence'))
|
||||
driver = await call('POST', `${driverBase}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '本机模拟资质审核' })
|
||||
const vehicle = await call('POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: `TEST-DT-${marker.slice(-10)}`, model: '26ft Box Truck', fuelConsumptionPer100Km:22, mileageKm:18000, driverOrDispatchCompany:marker, phone:'0000000000', region:'OVERSEAS', ownership:'OWNED', status:'IN_SERVICE', remark:marker, maxPalletSpaces:12,maxLoadKg:8000,maxVolumeCbm:45 })
|
||||
report.resources = { vehicleId:vehicle.id,driverId:driver.id }; await save(); return report.resources
|
||||
}
|
||||
async function assign(detail, resource) { if(detail.execution.trip.vehicleId) return detail; return call('POST', `${base}/${detail.dispatchNo}/assign`, { recordVersion:detail.execution.trip.recordVersion, ...resource, reason:marker }) }
|
||||
async function release(detail) {
|
||||
for(const kind of ['VEHICLE','DRIVER']) {
|
||||
detail = await read(detail.dispatchNo)
|
||||
if(detail.execution.trip[kind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) continue
|
||||
await act(detail,'release',{resourceKind:kind,actualEndAt:new Date().toISOString(),endLocation:'Ontario TEST warehouse',vehicleStatus:kind==='VEHICLE'?'IN_SERVICE':null})
|
||||
}
|
||||
return read(detail.dispatchNo)
|
||||
}
|
||||
async function truck(customer,key) {
|
||||
if(report.documents[key]) return call('GET',`${trucks}/${report.documents[key].orderNo}?customerId=${customer.id}`)
|
||||
const body=request(key,{vehicleType:'PALLET_15',origin:'TEST取货地',originAddress:'100 TEST Origin Road, Ontario, CA',destination:'TEST送货地',destinationAddress:'200 TEST Destination Road, Ontario, CA',packageCount:4,totalWeightKg:40,totalVolumeCbm:1,budgetAmount:100,currency:'USD',originContactName:'本机验收员',originContactPhone:'0000000000',destinationContactName:'本机验收员',destinationContactPhone:'0000000000',pickupAt:new Date().toISOString().slice(0,19),deliveryAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:marker})
|
||||
await save();let detail=await call('POST',`${trucks}?customerId=${customer.id}`,body)
|
||||
report.documents[key]={customerId:customer.id,orderNo:detail.summary.orderNo};await save();return detail
|
||||
}
|
||||
async function truckReady(detail) {
|
||||
const path=`${trucks}/${detail.summary.orderNo}`, query=`?customerId=${detail.summary.customerId}`
|
||||
const step=async(suffix,body={})=>call('POST',`${path}/${suffix}${query}`,{version:detail.summary.version,idempotencyKey:randomUUID(),remark:marker,...body})
|
||||
if(detail.summary.status==='PENDING_REVIEW')detail=await step('review',{decision:'APPROVE'})
|
||||
if(detail.summary.status==='WAITING_INBOUND')detail=await step('receive-pallets',{actualPalletCount:1,packageCount:4,palletPackageCounts:[4]})
|
||||
if(detail.summary.status==='WAITING_CONSOLIDATION')detail=await step('allocate-pallets',{palletIds:detail.pallets.map(row=>row.id)})
|
||||
if(detail.summary.status==='ALLOCATED')detail=await step('prepare-dispatch')
|
||||
check(detail.summary.status==='PENDING_DISPATCH','本土卡车原单审核、实收和分配后进入待派车');return detail
|
||||
}
|
||||
async function truckPlan(source,warehouse,key) {
|
||||
if(report.documents[key])return read(report.documents[key].dispatchNo)
|
||||
const body=request(key,{customerId:source.summary.customerId,orderNo:source.summary.orderNo,warehouseId:warehouse.id,plannedDepartureAt:new Date(Date.now()-300000).toISOString(),expectedDriverEndAt:new Date(Date.now()+86400000).toISOString()});await save()
|
||||
const detail=await call('POST',`${base}/truck`,body);report.documents[key]={dispatchNo:detail.dispatchNo,orderNo:body.orderNo};await save()
|
||||
check((await call('POST',`${base}/truck`,body)).dispatchNo===detail.dispatchNo,'本土卡车安排幂等重放');return detail
|
||||
}
|
||||
async function measure(source) {
|
||||
const path=`/api/pda/v1/last-leg/warehouse-transfers/${source.transferNo}`
|
||||
let detail=await api(pda,'GET',path)
|
||||
if(detail.status==='WAIT_CUSTOMER_CONFIRMATION')return
|
||||
if(!detail.pallets.length)detail=await api(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:randomUUID()})
|
||||
let pallet=detail.pallets[0]
|
||||
if(pallet.status==='DRAFT')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:randomUUID()})
|
||||
pallet=detail.pallets.find(row=>row.id===pallet.id)
|
||||
if(pallet.status!=='BOUND')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:randomUUID(),palletCode:pallet.palletNo})
|
||||
const boxes=detail.cartons.map(box=>({cartonMark:box.cartonMark,transferPalletId:pallet.id,length:40,width:30,height:25,weight:4,items:[{skuCode:detail.items[0].skuCode,quantity:8}]}))
|
||||
if(!detail.cartons.some(row=>row.countedAt))detail=await api(pda,'POST',`${path}/cartons/count`,{idempotencyKey:randomUUID(),forecastPackageCount:detail.packageCount,boxes})
|
||||
const photo=await api(pda,'POST',`${path}/measurement-photos/files`,image('warehouse-count'))
|
||||
detail=await api(pda,'POST',`${path}/palletize`,{idempotencyKey:randomUUID(),photoFileIds:[photo.fileId]})
|
||||
check(detail.status==='WAIT_CUSTOMER_CONFIRMATION'&&detail.orderStatus==='INBOUND_COMPLETED','取货到仓后仓库独立清点、测量、拍照完成入库');source.palletNo=pallet.palletNo;await save()
|
||||
}
|
||||
try {
|
||||
const login=await api('','POST','/api/auth/login',{loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD});admin=login.accessToken
|
||||
// 本机旧权限目录只补正式排车资源,并授权本轮专用角色,不改既有用户或角色。
|
||||
const required=['list','create','edit','depart','cancel'].map(key=>`platform:overseas-warehouse:dispatch:${key}`)
|
||||
if(!report.operatorUsername){
|
||||
const permissions=await call('GET','/api/platform/v1/iam/permissions')
|
||||
const parent=permissions.find(row=>row.permissionCode===required[0]);check(parent,'存在正式排车列表权限')
|
||||
for(const code of required)if(!permissions.some(row=>row.permissionCode===code))permissions.push(await call('POST','/api/platform/v1/iam/permissions',{type:'BUTTON',code,name:`排车${code.split(':').at(-1)}`,parentId:parent.id,httpMethod:'POST'}))
|
||||
const role=await call('POST','/api/platform/v1/iam/roles',{roleCode:`${marker}_OPERATOR`,roleName:`${marker}本机排车验收`,description:'仅本轮本机业务验收'})
|
||||
const allowed=new Set([...login.authorities,...required])
|
||||
await call('PUT','/api/platform/v1/iam/roles/permissions',{roleId:role.id,permissionIds:permissions.filter(row=>allowed.has(row.permissionCode)).map(row=>row.id)})
|
||||
const username=`dispatch_dt_${marker.replace(/\D/g,'')}`
|
||||
await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机调度验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],status:'ACTIVE'})
|
||||
report.operatorUsername=username;await save()
|
||||
}
|
||||
admin=(await api('','POST','/api/auth/login',{loginId:report.operatorUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken
|
||||
const customers=(await call('GET','/api/platform/v1/iam/tenants?page=1&size=200')).records.filter(row=>row.status==='ACTIVE')
|
||||
check(customers.length>=2,'有两个有效客户验证同仓跨客户取货')
|
||||
const warehouses=(await call('GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
|
||||
const warehouse=warehouses.find(row=>row.warehouseCode==='US-LAX-01')||warehouses[0];report.warehouseId=warehouse.id
|
||||
if(!report.pdaUsername){const role=(await call('GET','/api/platform/v1/iam/roles')).find(row=>row.roleCode==='OVERSEAS_WAREHOUSE_PDA_OPERATOR'&&row.status==='ACTIVE');check(role,'存在海外PDA角色');const username=`pda_dt_${marker.replace(/\D/g,'')}`;await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机排车验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],overseasWarehouseId:warehouse.id,status:'ACTIVE'});report.pdaUsername=username;await save()}
|
||||
pda=(await api('','POST','/api/auth/login',{loginId:report.pdaUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken
|
||||
const a=await transfer(customers[0],warehouse,'pickupA'),b=await transfer(customers[1],warehouse,'pickupB'),c=await transfer(customers[0],warehouse,'pickupCancelSource')
|
||||
let shared=await pickup([a,b],'pickupCompleted'),cancelled=await pickup([c],'pickupCancelled')
|
||||
const resource=await resources(warehouse);shared=await assign(shared,resource)
|
||||
if(cancelled.execution.trip.status==='PLANNED'){
|
||||
await call('POST',`${base}/${cancelled.dispatchNo}/assign`,{recordVersion:cancelled.execution.trip.recordVersion,...resource,reason:marker},'A0409')
|
||||
await act(cancelled,'cancel');cancelled=await read(cancelled.dispatchNo)
|
||||
}
|
||||
check(cancelled.execution.trip.status==='CANCELLED','取消取货排车生效')
|
||||
const replanned=await pickup([c],'pickupPendingSample');check(replanned.dispatchNo!==cancelled.dispatchNo,'取货取消后可重新安排新排车,保留待操作样单')
|
||||
if(shared.execution.trip.status==='PLANNED'){
|
||||
await api(pda,'POST',`/api/pda/v1/last-leg/warehouse-transfers/${a.transferNo}/pallets/drafts`,{idempotencyKey:randomUUID()},'A0409')
|
||||
await call('POST',`${base}/pickup`,{...report.requests.pickupCompleted,idempotencyKey:randomUUID()},'A0409')
|
||||
await act(shared,'pickup/depart');shared=await read(shared.dispatchNo)
|
||||
await act(shared,'pickup/complete',{},'A0409')
|
||||
}
|
||||
for(const source of [a,b]){
|
||||
shared=await read(shared.dispatchNo);let task=shared.pickups.find(row=>row.transferNo===source.transferNo)
|
||||
const suffix=`pickup/${source.customerId}/${source.transferNo}`
|
||||
if(task.status==='ASSIGNED'){
|
||||
if(source===b&&report.pickupEvidenceA)await act(shared,`${suffix}/picked`,{actualPackages:3,evidenceFileIds:[report.pickupEvidenceA]},'A0400')
|
||||
const file=await call('POST',`${base}/${shared.dispatchNo}/${suffix}/files`,image(source.transferNo))
|
||||
if(source===a){report.pickupEvidenceA=file.fileId;await save()}
|
||||
const body={...command(shared),actualPackages:3,evidenceFileIds:[file.fileId],remark:marker}
|
||||
await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body);await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body)
|
||||
await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,{...body,actualPackages:4},'A0409')
|
||||
const original=await call('GET',`${transfers}/${source.transferNo}?customerId=${source.customerId}`)
|
||||
check(original.packageCount===3&&original.status==='WAIT_PDA_RECEIVING'&&JSON.stringify(original.cartons.map(row=>row.cartonMark))===JSON.stringify(source.cartonMarks),'取货事实不篡改预报箱数、箱唛及仓库收货状态')
|
||||
}
|
||||
shared=await read(shared.dispatchNo);task=shared.pickups.find(row=>row.transferNo===source.transferNo)
|
||||
if(task.status==='PICKED')await act(shared,`${suffix}/arrive`)
|
||||
}
|
||||
shared=await read(shared.dispatchNo);if(shared.execution.trip.status==='IN_TRANSIT')await act(shared,'pickup/complete')
|
||||
shared=await release(await read(shared.dispatchNo));check(shared.execution.trip.status==='COMPLETED','到仓取货排车完成并分别释放司机车辆')
|
||||
await measure(a)
|
||||
let source=await truck(customers[0],'truckOriginal')
|
||||
if(!report.documents.truckCancelled)source=await truckReady(source)
|
||||
let native=await truckPlan(source,warehouse,'truckCancelled')
|
||||
if(native.execution.trip.status==='PLANNED'){await act(native,'cancel');native=await read(native.dispatchNo)}
|
||||
source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`)
|
||||
if(!report.documents.truckCompleted)check(source.summary.status==='PENDING_DISPATCH','取消本土卡车排车回退原单待派车')
|
||||
native=await truckPlan(source,warehouse,'truckCompleted')
|
||||
await call('POST',`${base}/truck`,{...report.requests.truckCompleted,plannedDepartureAt:new Date().toISOString()},'A0409')
|
||||
if(native.execution.trip.status==='PLANNED'){
|
||||
await call('POST',`${trucks}/${source.summary.orderNo}/start-transit?customerId=${source.summary.customerId}`,{version:source.summary.version,idempotencyKey:randomUUID(),remark:marker},'A0409')
|
||||
native=await assign(native,resource)
|
||||
await act(native,'truck/depart',{recordVersion:native.execution.trip.recordVersion-1},'A0409')
|
||||
await act(native,'truck/depart');native=await read(native.dispatchNo)
|
||||
}
|
||||
if(native.execution.trip.status==='IN_TRANSIT'){
|
||||
await act(native,'truck/complete',{evidenceFileIds:[]},'A0409')
|
||||
const file=await call('POST',`${base}/${native.dispatchNo}/truck/files`,image('truck-completion'))
|
||||
const body={...command(native),evidenceFileIds:[file.fileId]};await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body);await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body)
|
||||
await call('POST',`${base}/${native.dispatchNo}/truck/complete`,{...body,evidenceFileIds:[]},'A0409')
|
||||
}
|
||||
native=await release(await read(native.dispatchNo))
|
||||
source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`)
|
||||
check(native.execution.trip.status==='COMPLETED'&&source.summary.status==='COMPLETED'&&native.completionEvidence.length>0,'本土卡车排车完成、凭证持久化、原单同步完成')
|
||||
for(const doc of [shared,native,replanned]){
|
||||
const root=await call('GET',`/api/platform/v1/last-leg/dispatches/${doc.dispatchNo}`)
|
||||
check(root.taskType===doc.taskType,'同一个现有排车详情返回正确任务类型')
|
||||
}
|
||||
report.final={pickup:shared.dispatchNo,truck:native.dispatchNo,pending:replanned.dispatchNo,truckOriginal:source.summary.orderNo};report.passed=true;report.completedAt=new Date().toISOString();await save();console.log(JSON.stringify({passed:true,output,...report.final}))
|
||||
} catch(error){report.passed=false;report.lastError=String(error);await save();console.error(String(error));process.exitCode=1}
|
||||
Reference in New Issue
Block a user