Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35a1b835fe | ||
|
|
9f6e43ef3f | ||
|
|
868557d79d | ||
|
|
137efc6809 | ||
|
|
34b90f311a | ||
|
|
6771eba2d4 | ||
|
|
0dd9c8db07 | ||
|
|
0ff1eefff6 |
@@ -0,0 +1,10 @@
|
|||||||
|
import subprocess, shutil, xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
root=Path.cwd()
|
||||||
|
report=root/'lastleg-service/target/surefire-reports/TEST-com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.xml'
|
||||||
|
properties=ET.parse(report).getroot().find('properties')
|
||||||
|
classpath=next(item.attrib['value'] for item in properties if item.attrib['name']=='java.class.path')
|
||||||
|
sources=[str(root/'lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip'/name) for name in ['FleetTripCalendarServiceTest.java','FleetTripCalendarMapperTest.java']]
|
||||||
|
subprocess.run(['javac','-encoding','UTF-8','--release','17','-cp',classpath,'-d',str(root/'lastleg-service/target/test-classes'),*sources],check=True)
|
||||||
|
result=subprocess.run([shutil.which('mvn'),' -T'.strip(),'1','-DforkCount=1','-DreuseForks=true','-pl','lastleg-service','surefire:test','-Dtest=FleetTripCalendarServiceTest,FleetTripCalendarMapperTest'],check=False)
|
||||||
|
raise SystemExit(result.returncode)
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
# 单测试环境数据库空库初始化
|
# 单测试环境数据库空库初始化
|
||||||
|
|
||||||
客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。
|
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
||||||
|
|
||||||
|
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
||||||
|
|
||||||
|
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
|
||||||
|
|
||||||
|
客户“确认计费重”统一使用 `fl_inbound_fee` 最新已推送费用版本及其客户确认记录,不再维护独立重量确认表或接口。源建表定义、旧创建脚本与空库基线已移除;已有 TEST 先发布移除旧引用的 Firstleg 版本,再执行 `migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql` 删除 `fl_chargeable_weight_confirmation`,不修改费用、订单或测量数据。脚本存在不表示已执行或部署。
|
||||||
|
|
||||||
费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。
|
费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 排车新编号所需独立结构;仅新建序列表,不改写已有排车业务号或业务数据。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||||
|
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||||
|
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||||
|
PRIMARY KEY(business_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
-- 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,15 +0,0 @@
|
|||||||
-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。
|
|
||||||
-- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。
|
|
||||||
-- 客户独立确认计费重,不回填既有费用确认。
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
|
|
||||||
id BIGINT NOT NULL COMMENT '确认记录ID',
|
|
||||||
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
|
|
||||||
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
|
|
||||||
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
|
|
||||||
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
|
|
||||||
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
|
|
||||||
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
|
|
||||||
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
|
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
|
|
||||||
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
|
|
||||||
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 客户“确认计费重”统一确认 fl_inbound_fee 的当前推送费用版本。
|
||||||
|
-- 先发布已移除旧重量确认接口及查询的 Firstleg 版本,再执行本脚本。
|
||||||
|
-- 仅删除旧独立重量确认记录,不修改费用快照、订单或测量数据。
|
||||||
|
DROP TABLE IF EXISTS baiyue_firstleg.fl_chargeable_weight_confirmation;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。
|
||||||
|
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='排车运输执行命令与凭证账本';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 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,20 +4437,6 @@ CREATE TABLE fl_inbound_split_item (
|
|||||||
|
|
||||||
SET SESSION foreign_key_checks = 1;
|
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
|
-- 服务 schema:baiyue_file
|
||||||
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
USE baiyue_file;
|
USE baiyue_file;
|
||||||
@@ -6856,6 +6842,18 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
|
|||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_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_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_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);
|
||||||
@@ -8385,4 +8383,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_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));
|
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, 228 AS initialized_tables;
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 排车运输接入数据库变更汇总
|
||||||
|
|
||||||
|
执行文件:[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,6 +416,7 @@ CREATE TABLE ll_dispatch_item (
|
|||||||
CREATE TABLE ll_dispatch_order (
|
CREATE TABLE ll_dispatch_order (
|
||||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||||
dispatch_no VARCHAR(64) 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',
|
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||||
@@ -1865,6 +1866,12 @@ CREATE TABLE ll_order_no_daily_sequence (
|
|||||||
PRIMARY KEY (business_date)
|
PRIMARY KEY (business_date)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
|
) 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 (
|
CREATE TABLE ll_order_operation_log (
|
||||||
id bigint NOT NULL COMMENT '操作记录主键',
|
id bigint NOT NULL COMMENT '操作记录主键',
|
||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
|
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
|
||||||
@@ -3216,6 +3223,7 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
||||||
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
||||||
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
||||||
|
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
|
||||||
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
||||||
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
||||||
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
||||||
@@ -3230,6 +3238,7 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
|
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 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),
|
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),
|
PRIMARY KEY(id),
|
||||||
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
||||||
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
||||||
@@ -4882,4 +4891,122 @@ CREATE TABLE ll_fleet_pickup_history (
|
|||||||
KEY idx_pickup_history_trip(trip_no,id)
|
KEY idx_pickup_history_trip(trip_no,id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
|
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
|
||||||
|
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
||||||
|
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
|
||||||
|
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
|
||||||
|
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
|
||||||
|
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
|
||||||
|
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
|
||||||
|
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
|
||||||
|
PRIMARY KEY (dispatch_id),
|
||||||
|
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
|
||||||
|
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
|
||||||
|
CONSTRAINT ck_owned_business_resource_mode CHECK (
|
||||||
|
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
|
||||||
|
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
|
||||||
|
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
|
||||||
|
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
|
||||||
|
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
|
||||||
|
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
|
||||||
|
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
|
||||||
|
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
|
||||||
|
photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照',
|
||||||
|
created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识',
|
||||||
|
updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id),
|
||||||
|
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
|
||||||
|
CONSTRAINT ck_owned_business_money CHECK (
|
||||||
|
(currency IS NULL AND total_amount IS NULL)
|
||||||
|
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
|
||||||
|
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
|
||||||
|
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
|
||||||
|
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
|
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
|
||||||
|
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
|
||||||
|
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
|
||||||
|
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
|
||||||
|
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
|
||||||
|
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
|
||||||
|
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
|
||||||
|
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
|
||||||
|
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
|
||||||
|
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||||
|
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
|
||||||
|
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
|
||||||
|
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识',
|
||||||
|
reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明',
|
||||||
|
snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照',
|
||||||
|
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version),
|
||||||
|
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
|
||||||
|
|
||||||
|
-- 运输原单接入现有排车,不产生仓内库存事实。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
CREATE TABLE ll_dispatch_truck_task (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||||
|
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||||
|
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||||
|
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
||||||
|
KEY idx_transport_order(customer_id,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
||||||
|
CREATE TABLE ll_dispatch_transport_create (
|
||||||
|
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
||||||
|
CREATE TABLE ll_dispatch_transport_command (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||||
|
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||||
|
PRIMARY KEY(dispatch_no,request_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
||||||
|
|
||||||
|
-- Region: OVERSEAS
|
||||||
|
-- Schema: baiyue_lastleg
|
||||||
|
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
|
||||||
|
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
|
||||||
|
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
|
||||||
|
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
|
||||||
|
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
||||||
|
EXECUTE looseboard_demand_stmt;
|
||||||
|
DEALLOCATE PREPARE looseboard_demand_stmt;
|
||||||
|
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
|
||||||
|
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
||||||
|
EXECUTE looseboard_demand_stmt;
|
||||||
|
DEALLOCATE PREPARE looseboard_demand_stmt;
|
||||||
|
|
||||||
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 236 AS initialized_tables;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ enum BusinessFileCategory {
|
|||||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
@@ -71,6 +73,11 @@ enum BusinessFileCategory {
|
|||||||
|
|
||||||
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
||||||
void validateOwner(long tenantId) {
|
void validateOwner(long tenantId) {
|
||||||
|
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
|
||||||
|
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
|
||||||
|
&& tenantId != 0) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||||
|
}
|
||||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||||
&& tenantId != 0) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: 自有业务排车运营照片归属契约
|
||||||
|
version: 1.0.0
|
||||||
|
description: >-
|
||||||
|
自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。
|
||||||
|
复用InternalBusinessFileController,不增加公网File路径。
|
||||||
|
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
|
||||||
|
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
|
||||||
|
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
||||||
|
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
|
||||||
|
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
|
||||||
|
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
|
||||||
|
paths: {}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.file.entity.FileObjectEntity;
|
||||||
|
import com.baiyue.file.mapper.FileObjectMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class OwnedBusinessPhotoFileTest {
|
||||||
|
private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO";
|
||||||
|
private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO";
|
||||||
|
@TempDir Path root;
|
||||||
|
|
||||||
|
@BeforeAll static void columns() {
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||||
|
void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) {
|
||||||
|
var files = mock(FileObjectMapper.class);
|
||||||
|
var storage = mock(BusinessFileStorage.class);
|
||||||
|
var service = service(files, storage);
|
||||||
|
var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1});
|
||||||
|
for (long owner : new long[]{-1, 1, 900})
|
||||||
|
assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class);
|
||||||
|
for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""})
|
||||||
|
assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class);
|
||||||
|
verifyNoInteractions(files, storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||||
|
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
|
||||||
|
var storage = new BusinessFileStorage();
|
||||||
|
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||||
|
storage.initializeRoot();
|
||||||
|
var category = BusinessFileCategory.require(type);
|
||||||
|
for (String format : new String[]{"jpeg", "png"}) {
|
||||||
|
byte[] bytes = image(format);
|
||||||
|
var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes);
|
||||||
|
var saved = storage.store(0, "71", upload, category);
|
||||||
|
assertThat(storage.read(saved.objectKey())).isEqualTo(bytes);
|
||||||
|
}
|
||||||
|
for (var upload : new MockMultipartFile[]{
|
||||||
|
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
|
||||||
|
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
|
||||||
|
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
|
||||||
|
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
|
||||||
|
MultipartFile oversized = mock(MultipartFile.class);
|
||||||
|
when(oversized.isEmpty()).thenReturn(false);
|
||||||
|
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
|
||||||
|
assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class);
|
||||||
|
verify(oversized, never()).getInputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */
|
||||||
|
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||||
|
void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception {
|
||||||
|
var files = mock(FileObjectMapper.class);
|
||||||
|
var storage = mock(BusinessFileStorage.class);
|
||||||
|
var service = service(files, storage);
|
||||||
|
byte[] bytes = image("png");
|
||||||
|
var row = new FileObjectEntity();
|
||||||
|
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
|
||||||
|
row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length);
|
||||||
|
row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
|
||||||
|
when(files.selectOne(any())).thenAnswer(call -> {
|
||||||
|
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||||
|
assertThat(bound(query, "status")).isEqualTo("ACTIVE");
|
||||||
|
assertThat(bound(query, "deleted")).isEqualTo(false);
|
||||||
|
return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type"))
|
||||||
|
&& "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null;
|
||||||
|
});
|
||||||
|
when(storage.read("owned-photo")).thenReturn(bytes);
|
||||||
|
assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type);
|
||||||
|
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes);
|
||||||
|
clearInvocations(storage);
|
||||||
|
for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING,
|
||||||
|
"FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) {
|
||||||
|
assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||||
|
assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||||
|
}
|
||||||
|
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||||
|
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||||
|
assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class);
|
||||||
|
verifyNoInteractions(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object bound(LambdaQueryWrapper<FileObjectEntity> query, String column) {
|
||||||
|
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
|
||||||
|
.matcher(query.getSqlSegment());
|
||||||
|
assertThat(matcher.find()).as("scope column %s", column).isTrue();
|
||||||
|
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||||
|
var service = new InternalBusinessFileService();
|
||||||
|
ReflectionTestUtils.setField(service, "files", files);
|
||||||
|
ReflectionTestUtils.setField(service, "storage", storage);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] image(String format) throws Exception {
|
||||||
|
var bytes = new ByteArrayOutputStream();
|
||||||
|
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
|
||||||
|
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-4
@@ -23,6 +23,7 @@ public interface MerchantWorkbenchFilterMapper {
|
|||||||
FROM fl_inbound_order i
|
FROM fl_inbound_order i
|
||||||
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
|
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
|
||||||
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL
|
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL
|
||||||
|
AND i.process_status != 'CANCELLED' AND i.measure_review_status='APPROVED'
|
||||||
AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL
|
AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
|
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
|
||||||
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
|
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
|
||||||
@@ -63,13 +64,13 @@ public interface MerchantWorkbenchFilterMapper {
|
|||||||
ORDER BY i.order_id
|
ORDER BY i.order_id
|
||||||
""")
|
""")
|
||||||
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
|
||||||
/** Only physically loaded fragments can expose container milestones to the customer. */
|
/** 仅按本客户实际装柜片段读取进度;清关资料保存后立即显示已清关。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
|
||||||
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
|
WHEN EXISTS (SELECT 1 FROM fl_container_document d
|
||||||
WHERE n.container_booking_id=owned.container_booking_id
|
WHERE d.container_booking_id=owned.container_booking_id
|
||||||
AND n.standard_node='PICKUP_READY' AND n.actual_at IS NOT NULL) THEN 3
|
AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL) THEN 3
|
||||||
WHEN EXISTS (SELECT 1 FROM fl_customs_container c
|
WHEN EXISTS (SELECT 1 FROM fl_customs_container c
|
||||||
WHERE c.loading_plan_id=owned.loading_plan_id AND c.status='COMPLETED') THEN 2
|
WHERE c.loading_plan_id=owned.loading_plan_id AND c.status='COMPLETED') THEN 2
|
||||||
ELSE 1 END AS stage
|
ELSE 1 END AS stage
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.baiyue.firstleg.repository;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
||||||
|
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 客户工作台筛选事实的持久化入口。 */
|
||||||
|
@Repository
|
||||||
|
public class MerchantWorkbenchFilterRepository {
|
||||||
|
private final MerchantWorkbenchFilterMapper mapper;
|
||||||
|
|
||||||
|
/** 注入客户隔离的筛选查询。 */
|
||||||
|
public MerchantWorkbenchFilterRepository(MerchantWorkbenchFilterMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询物流主状态对应的订单范围。 */
|
||||||
|
public List<Long> byProcessStatuses(long tenantId, List<String> statuses) {
|
||||||
|
return mapper.byProcessStatuses(tenantId, statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只返回最新已推送费用仍待客户确认的订单。 */
|
||||||
|
public List<Long> pendingFees(long tenantId) {
|
||||||
|
return mapper.pendingFees(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询待客户补齐报关资料的订单。 */
|
||||||
|
public List<Long> pendingCustoms(long tenantId) {
|
||||||
|
return mapper.pendingCustoms(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询客户订单的实际履约片段。 */
|
||||||
|
public List<MerchantWorkbenchFilters.Fragment> progressFragments(long tenantId, List<Long> orderIds) {
|
||||||
|
return mapper.progressFragments(tenantId, orderIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -1,30 +1,30 @@
|
|||||||
package com.baiyue.firstleg.service;
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
||||||
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
|
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MerchantWorkbenchFilterService {
|
public class MerchantWorkbenchFilterService {
|
||||||
@Resource private MerchantWorkbenchFilterMapper mapper;
|
@Resource private MerchantWorkbenchFilterRepository repository;
|
||||||
@Resource private UnifiedInboundQueryService unified;
|
@Resource private UnifiedInboundQueryService unified;
|
||||||
@Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights;
|
|
||||||
|
|
||||||
/** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */
|
/** 精确详情只读进度;确认计费重待办统一使用最新已推送费用的客户确认状态。 */
|
||||||
@org.springframework.transaction.annotation.Transactional(readOnly=true)
|
@org.springframework.transaction.annotation.Transactional(readOnly=true)
|
||||||
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
|
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
|
||||||
if (request.orderIds() != null) {
|
if (request.orderIds() != null) {
|
||||||
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
|
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
|
||||||
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
|
progress(repository.progressFragments(request.tenantId(), request.orderIds())));
|
||||||
}
|
}
|
||||||
return new MerchantWorkbenchFilters.Response(
|
return new MerchantWorkbenchFilters.Response(
|
||||||
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
|
request.mainStatus() == null ? null : repository.byProcessStatuses(request.tenantId(),
|
||||||
unified.internalStatuses(request.mainStatus())),
|
unified.internalStatuses(request.mainStatus())),
|
||||||
weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
|
repository.pendingFees(request.tenantId()), repository.pendingCustoms(request.tenantId()),
|
||||||
progress(mapper.progressFragments(request.tenantId(), null)));
|
progress(repository.progressFragments(request.tenantId(), null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将实装片段聚合为订单级履约进度。 */
|
||||||
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
|
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
|
||||||
var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId));
|
var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId));
|
||||||
return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> {
|
return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> {
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package com.baiyue.firstleg.workflow;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
|
||||||
import com.baiyue.common.core.exception.BizException;
|
|
||||||
import java.util.*;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
/** 客户独立确认计费重,金额确认和主流程不受影响。 */
|
|
||||||
@Service
|
|
||||||
public class ChargeableWeightService {
|
|
||||||
private final WorkflowFactRepository repository;
|
|
||||||
/** 注入当前事实和确认历史。 */
|
|
||||||
public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; }
|
|
||||||
/** 客户仅能读取本订单已审核的计费重。 */
|
|
||||||
public List<WorkflowFacts.Weight> read(long tenant,long orderId) {
|
|
||||||
return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList();
|
|
||||||
}
|
|
||||||
/** 客户工作台按独立确认事实计算待办,不借用费用确认。 */
|
|
||||||
public List<Long> pendingOrders(long tenant) {
|
|
||||||
var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet<Long>();
|
|
||||||
for(int offset=0;offset<ids.size();offset+=500) for(var f:repository.facts(ids.subList(offset,Math.min(offset+500,ids.size()))))
|
|
||||||
if(f.tenantId()==tenant && eligible(f) && !weight(f).confirmed()) orders.add(f.orderId());
|
|
||||||
return List.copyOf(orders);
|
|
||||||
}
|
|
||||||
/** 值和测量轮次变化使旧确认失效,金额变化不影响计费重。 */
|
|
||||||
public static String fingerprint(WorkflowFacts.Fact f) {
|
|
||||||
try { String value=f.inboundId()+"|"+f.chargeableWeightKg().stripTrailingZeros().toPlainString()+"|"+f.measureCompletedAt();
|
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
|
||||||
}
|
|
||||||
/** 未审核或非正数不允许确认。 */
|
|
||||||
public static boolean eligible(WorkflowFacts.Fact f) { return "APPROVED".equals(f.reviewStatus()) && f.chargeableWeightKg()!=null && f.chargeableWeightKg().signum()>0; }
|
|
||||||
/** 只认可当前版本的确认。 */
|
|
||||||
public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) {
|
|
||||||
String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint());
|
|
||||||
return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null);
|
|
||||||
}
|
|
||||||
/** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */
|
|
||||||
@Transactional
|
|
||||||
public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) {
|
|
||||||
if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符");
|
|
||||||
var inbound=repository.lock(tenant,id);
|
|
||||||
if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在");
|
|
||||||
var replay=repository.command(tenant,operator,key);
|
|
||||||
if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重");
|
|
||||||
var facts=repository.facts(List.of(id));
|
|
||||||
if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重");
|
|
||||||
var f=facts.get(0);
|
|
||||||
if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过");
|
|
||||||
var current=weight(f);
|
|
||||||
if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认");
|
|
||||||
if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now()));
|
|
||||||
return weight(repository.facts(List.of(id)).get(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package com.baiyue.firstleg.workflow;
|
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
|
||||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import java.util.List;
|
|
||||||
/** 客户计费重确认入口,独立于费用确认。 */
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/merchant/v1/first-leg")
|
|
||||||
public class MerchantChargeableWeightController {
|
|
||||||
private final ChargeableWeightService service;
|
|
||||||
/** 注入独立确认服务。 */
|
|
||||||
public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; }
|
|
||||||
/** 客户范围取认证上下文。 */
|
|
||||||
@GetMapping("/orders/{orderId}/chargeable-weights")
|
|
||||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
|
||||||
public ApiResponse<List<WorkflowFacts.Weight>> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); }
|
|
||||||
/** 不触发费用确认与金额锁定。 */
|
|
||||||
@com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重")
|
|
||||||
@PostMapping("/chargeable-weights/{inboundId}/confirm")
|
|
||||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
|
||||||
public ApiResponse<WorkflowFacts.Weight> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); }
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.baiyue.firstleg.workflow;
|
package com.baiyue.firstleg.workflow;
|
||||||
import org.apache.ibatis.annotations.*;
|
import org.apache.ibatis.annotations.*;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
/** 柜事实和独立确认SQL入口。 */
|
/** 柜事实与最新费用确认状态的只读 SQL 入口。 */
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface WorkflowFactMapper {
|
public interface WorkflowFactMapper {
|
||||||
String ACTUAL="""
|
String ACTUAL="""
|
||||||
@@ -13,29 +13,30 @@ public interface WorkflowFactMapper {
|
|||||||
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
|
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
|
||||||
""";
|
""";
|
||||||
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
|
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
|
||||||
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,i.chargeable_weight_kg,i.measure_completed_at,"
|
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,"
|
||||||
+"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_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 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='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 (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,"
|
||||||
+"(SELECT w.fingerprint FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmation_fingerprint,"
|
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_customs_container c WHERE c.loading_plan_id=p.id AND c.status='COMPLETED')) customs_completed "
|
||||||
+"(SELECT w.confirmed_at FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmed_at "
|
|
||||||
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
|
+"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>")
|
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||||
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
|
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
|
||||||
/** 锁定客户自己的入仓单,串行确认与测量写入。 */
|
/** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */
|
||||||
@Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE")
|
@Select("""
|
||||||
com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id);
|
<script>
|
||||||
/** 客户订单下全部独立入仓单。 */
|
SELECT i.tenant_id, i.order_id,
|
||||||
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id")
|
CASE WHEN f.customer_confirmed_at IS NULL THEN 'WAIT_WEIGHT_CONFIRM'
|
||||||
List<Long> orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
|
ELSE 'WEIGHT_CONFIRMED' END AS main_status
|
||||||
/** 客户工作台只读取审核通过且有正计费重的来源。 */
|
FROM fl_inbound_order i
|
||||||
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id")
|
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
|
||||||
List<Long> weightCandidates(@Param("tenantId") long tenantId);
|
WHERE i.deleted=0 AND i.process_status != 'CANCELLED'
|
||||||
/** 成员幂等键不允许跨确认对象复用。 */
|
AND i.measure_review_status='APPROVED' AND f.fee_status='SENT'
|
||||||
@Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}")
|
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
|
||||||
WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key);
|
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
|
||||||
/** 独立记录,不修改费用或物流主状态。 */
|
AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id)))
|
||||||
@Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})")
|
AND i.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||||
int insert(WorkflowFacts.Confirmation value);
|
</script>
|
||||||
|
""")
|
||||||
|
List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(@Param("ids") List<Long> ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.baiyue.firstleg.workflow;
|
package com.baiyue.firstleg.workflow;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
/** 本地柜事实与确认记录的持久化入口。 */
|
/** 本地柜事实与费用确认状态的只读持久化入口。 */
|
||||||
@Repository
|
@Repository
|
||||||
public class WorkflowFactRepository {
|
public class WorkflowFactRepository {
|
||||||
private final WorkflowFactMapper mapper;
|
private final WorkflowFactMapper mapper;
|
||||||
@@ -9,14 +9,8 @@ public class WorkflowFactRepository {
|
|||||||
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
|
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
|
||||||
/** 空候选不读取数据。 */
|
/** 空候选不读取数据。 */
|
||||||
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
|
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
|
||||||
/** 锁定所属客户来源。 */
|
/** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */
|
||||||
public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); }
|
public List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(List<Long> ids) {
|
||||||
/** 查询订单下独立来源。 */
|
return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids);
|
||||||
public List<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
|
}
|
||||||
/** 客户计费重待确认候选。 */
|
|
||||||
public List<Long> weightCandidates(long tenant) { return mapper.weightCandidates(tenant); }
|
|
||||||
/** 读取成员确认命令。 */
|
|
||||||
public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); }
|
|
||||||
/** 追加确认历史。 */
|
|
||||||
public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
package com.baiyue.firstleg.workflow;
|
package com.baiyue.firstleg.workflow;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
/** 页签事实与独立计费重确认契约。 */
|
/** 订单业务页签的只读事实契约。 */
|
||||||
public final class WorkflowFacts {
|
public final class WorkflowFacts {
|
||||||
/** 仅承载查询及确认契约,不允许实例化。 */
|
/** 仅承载事实查询契约,不允许实例化。 */
|
||||||
private WorkflowFacts() {}
|
private WorkflowFacts() {}
|
||||||
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt,
|
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
|
||||||
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {}
|
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,boolean customsCompleted) {}
|
||||||
public record Source(long tenantId,long inboundId) {}
|
public record Source(long tenantId,long inboundId) {}
|
||||||
public record Request(List<Source> sources) {}
|
public record Request(List<Source> sources) {}
|
||||||
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
|
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
|
||||||
public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {}
|
|
||||||
public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {}
|
|
||||||
public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ public class WorkflowTabService {
|
|||||||
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
|
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
|
||||||
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
|
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
|
||||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
||||||
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
// 确认页签复用已推送费用版本。
|
||||||
|
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
|
||||||
|
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
|
||||||
|
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
||||||
|
}
|
||||||
|
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||||
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
||||||
if(!sources.isEmpty()) {
|
if(!sources.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
@@ -46,22 +51,23 @@ public class WorkflowTabService {
|
|||||||
}
|
}
|
||||||
return List.copyOf(result.values());
|
return List.copyOf(result.values());
|
||||||
}
|
}
|
||||||
/** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */
|
/** 分类为可重叠的物流待办/证据集合,客户确认由最新费用版本单独提供。 */
|
||||||
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
|
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
|
||||||
Set<String> keys=new LinkedHashSet<>();
|
Set<String> keys=new LinkedHashSet<>();
|
||||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||||
if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM");
|
|
||||||
boolean signed="DELIVERED".equals(process);
|
boolean signed="DELIVERED".equals(process);
|
||||||
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
|
// 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。
|
||||||
if(!f.departed() && !signed) {
|
boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded();
|
||||||
|
if(!clearanceStage && !signed) {
|
||||||
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
|
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
|
||||||
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
|
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
|
||||||
}
|
}
|
||||||
if(signed) keys.add("SIGNED");
|
if(signed) keys.add("SIGNED");
|
||||||
if(f.departed() && !signed) {
|
if(clearanceStage && !signed) {
|
||||||
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
|
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
|
||||||
if(o!=null) {
|
// 清关资料保存成功后才进入海外待办;出库事实优先于拆柜和排车待办。
|
||||||
if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
if(f.clearanceUploaded() && o!=null) {
|
||||||
|
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
||||||
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
||||||
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
||||||
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
-- 客户独立确认计费重,不回填既有费用确认。
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
|
|
||||||
id BIGINT NOT NULL COMMENT '确认记录ID',
|
|
||||||
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
|
|
||||||
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
|
|
||||||
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
|
|
||||||
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
|
|
||||||
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
|
|
||||||
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
|
|
||||||
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
|
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
|
|
||||||
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
|
|
||||||
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
|
|
||||||
@@ -355,7 +355,7 @@ paths:
|
|||||||
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
|
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
|
||||||
post:
|
post:
|
||||||
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
||||||
description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
||||||
security: [{internalServiceToken: []}]
|
security: [{internalServiceToken: []}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -388,11 +388,11 @@ paths:
|
|||||||
required: [feeOrderIds, customsOrderIds, progress]
|
required: [feeOrderIds, customsOrderIds, progress]
|
||||||
properties:
|
properties:
|
||||||
matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}}
|
matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}}
|
||||||
feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}}
|
feeOrderIds: {type: array, description: 待客户确认最新已推送费用版本的订单ID, items: {type: integer, format: int64}}
|
||||||
customsOrderIds: {type: array, items: {type: integer, format: int64}}
|
customsOrderIds: {type: array, items: {type: integer, format: int64}}
|
||||||
progress:
|
progress:
|
||||||
type: array
|
type: array
|
||||||
description: 实际装载片段投影;报关要求COMPLETED,目的港清关要求PICKUP_READY实际时间,缺失节点不推测。
|
description: 实际装载片段投影;报关要求COMPLETED,目的港清关以本柜DESTINATION_CLEARANCE资料成功绑定file_id为准,不等待PICKUP_READY船节点。
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
required: [orderId, stage, partial]
|
required: [orderId, stage, partial]
|
||||||
@@ -1674,7 +1674,7 @@ components:
|
|||||||
type: string
|
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]
|
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
|
nullable: true
|
||||||
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。
|
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
|
||||||
customerId: {type: integer, format: int64, nullable: true}
|
customerId: {type: integer, format: int64, nullable: true}
|
||||||
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
||||||
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
openapi: 3.0.3
|
|
||||||
info: {title: 客户独立计费重确认, version: 1.0.0}
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
paths:
|
|
||||||
/api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights:
|
|
||||||
get:
|
|
||||||
summary: 客户读取本票全部独立入仓计费重
|
|
||||||
description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。
|
|
||||||
parameters:
|
|
||||||
- {name: orderId, in: path, required: true, schema: {type: integer, format: int64}}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 标准ApiResponse,认证客户无可确认来源时返回空数组
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
code: {type: string, example: '200'}
|
|
||||||
message: {type: string}
|
|
||||||
data:
|
|
||||||
type: array
|
|
||||||
items: {$ref: '#/components/schemas/Weight'}
|
|
||||||
/api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm:
|
|
||||||
post:
|
|
||||||
summary: 客户独立确认当前计费重
|
|
||||||
description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。
|
|
||||||
parameters:
|
|
||||||
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}}
|
|
||||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [expectedFingerprint]
|
|
||||||
properties:
|
|
||||||
expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
code: {type: string, example: '200'}
|
|
||||||
message: {type: string}
|
|
||||||
data: {$ref: '#/components/schemas/Weight'}
|
|
||||||
components:
|
|
||||||
securitySchemes:
|
|
||||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
|
||||||
schemas:
|
|
||||||
Weight:
|
|
||||||
type: object
|
|
||||||
required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed]
|
|
||||||
properties:
|
|
||||||
inboundId: {type: string, description: 独立来源入仓单ID}
|
|
||||||
orderId: {type: string, description: 独立订单ID}
|
|
||||||
chargeableWeightKg: {type: number}
|
|
||||||
fingerprint: {type: string}
|
|
||||||
confirmed: {type: boolean}
|
|
||||||
confirmedAt: {type: string, format: date-time, nullable: true}
|
|
||||||
+9
-1
@@ -19,6 +19,7 @@ class MerchantWorkbenchFilterMapperTest {
|
|||||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||||
jdbc=new JdbcTemplate(ds);
|
jdbc=new JdbcTemplate(ds);
|
||||||
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))");
|
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))");
|
||||||
|
jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'");
|
||||||
jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
|
jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
|
||||||
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
|
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)");
|
jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)");
|
||||||
@@ -29,11 +30,12 @@ class MerchantWorkbenchFilterMapperTest {
|
|||||||
jdbc.execute("ALTER TABLE fl_loading_plan_item ADD id BIGINT GENERATED BY DEFAULT AS IDENTITY");
|
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_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_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)");
|
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 config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class);
|
||||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class);
|
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class);
|
||||||
jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
|
jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
|
||||||
jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')");
|
jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')");
|
||||||
jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
|
jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
|
||||||
jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)");
|
jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)");
|
||||||
@@ -94,6 +96,12 @@ class MerchantWorkbenchFilterMapperTest {
|
|||||||
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)");
|
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)");
|
||||||
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
|
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
|
||||||
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
|
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, 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);});
|
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'");
|
jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'");
|
||||||
|
|||||||
+9
-12
@@ -1,7 +1,7 @@
|
|||||||
package com.baiyue.firstleg.service;
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
||||||
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
|
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -9,25 +9,22 @@ import static org.assertj.core.api.Assertions.*;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class MerchantWorkbenchFilterServiceTest {
|
class MerchantWorkbenchFilterServiceTest {
|
||||||
/** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */
|
/** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */
|
||||||
@Test void customerQueueUsesIndependentWeightConfirmation() {
|
@Test void customerQueueUsesPublishedFeeConfirmation() {
|
||||||
var mapper=mock(MerchantWorkbenchFilterMapper.class);
|
var repository=mock(MerchantWorkbenchFilterRepository.class);
|
||||||
var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class);
|
|
||||||
var service=new MerchantWorkbenchFilterService();
|
var service=new MerchantWorkbenchFilterService();
|
||||||
ReflectionTestUtils.setField(service,"mapper",mapper);
|
ReflectionTestUtils.setField(service,"repository",repository);
|
||||||
ReflectionTestUtils.setField(service,"weights",weights);
|
when(repository.pendingFees(11)).thenReturn(List.of(101L));
|
||||||
when(weights.pendingOrders(11)).thenReturn(List.of(101L));
|
|
||||||
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
|
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
|
||||||
assertThat(response.feeOrderIds()).containsExactly(101L);
|
assertThat(response.feeOrderIds()).containsExactly(101L);
|
||||||
verify(weights).pendingOrders(11);
|
verify(repository).pendingFees(11);
|
||||||
verify(mapper,never()).pendingFees(anyLong());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
|
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
|
||||||
var mapper=mock(MerchantWorkbenchFilterMapper.class);
|
var mapper=mock(MerchantWorkbenchFilterRepository.class);
|
||||||
var unified=mock(UnifiedInboundQueryService.class);
|
var unified=mock(UnifiedInboundQueryService.class);
|
||||||
var service=new MerchantWorkbenchFilterService();
|
var service=new MerchantWorkbenchFilterService();
|
||||||
ReflectionTestUtils.setField(service,"mapper",mapper);
|
ReflectionTestUtils.setField(service,"repository",mapper);
|
||||||
ReflectionTestUtils.setField(service,"unified",unified);
|
ReflectionTestUtils.setField(service,"unified",unified);
|
||||||
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
|
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
|
||||||
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
|
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
@@ -15,8 +13,7 @@ import static org.mockito.Mockito.*;
|
|||||||
class WorkflowBoundaryTest {
|
class WorkflowBoundaryTest {
|
||||||
/** 真实开船来源的测试事实。 */
|
/** 真实开船来源的测试事实。 */
|
||||||
private WorkflowFacts.Fact fact() {
|
private WorkflowFacts.Fact fact() {
|
||||||
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
|
return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false,false);
|
||||||
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下游漏来源或返回其他客户均整体失败。 */
|
/** 下游漏来源或返回其他客户均整体失败。 */
|
||||||
@@ -36,28 +33,23 @@ class WorkflowBoundaryTest {
|
|||||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重测同值也要求重新确认,金额没有参与摘要。 */
|
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||||
@Test void repeatedMeasurementRequiresNewConfirmation() {
|
@Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() {
|
||||||
var original=fact();
|
var repository=mock(WorkflowFactRepository.class);
|
||||||
var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
|
var downstream=mock(LastlegWorkflowClient.class);
|
||||||
original.measureCompletedAt().plusSeconds(1),true,false,false,false,
|
var service=new WorkflowTabService(repository,downstream);
|
||||||
ChargeableWeightService.fingerprint(original),LocalDateTime.now());
|
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
|
||||||
assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse();
|
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||||
|
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||||
|
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
|
||||||
|
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
|
||||||
|
verifyNoInteractions(downstream);
|
||||||
|
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||||
|
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
|
||||||
|
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||||
|
new WorkflowFacts.Overseas(11,1,false,false,false,false))));
|
||||||
|
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
|
||||||
|
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */
|
|
||||||
@Test void confirmationUsesAuthenticatedCustomerAndOperator() {
|
|
||||||
var service=mock(ChargeableWeightService.class);
|
|
||||||
var controller=new MerchantChargeableWeightController(service);
|
|
||||||
var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of());
|
|
||||||
var request=new WorkflowFacts.Confirm("a".repeat(64));
|
|
||||||
assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class);
|
|
||||||
assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class);
|
|
||||||
verifyNoInteractions(service);
|
|
||||||
var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of());
|
|
||||||
controller.read(merchant,101);
|
|
||||||
controller.confirm(merchant,1,"key",request);
|
|
||||||
verify(service).read(11,101);
|
|
||||||
verify(service).confirm(11,7,1,"key",request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,52 @@
|
|||||||
package com.baiyue.firstleg.workflow;
|
package com.baiyue.firstleg.workflow;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
/** 取消回显、分柜隔离和独立计费重版本回归。 */
|
/** 取消回显、分柜隔离和资料页签回归。 */
|
||||||
class WorkflowBusinessTest {
|
class WorkflowBusinessTest {
|
||||||
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
|
/** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */
|
||||||
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
|
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
|
||||||
var time=LocalDateTime.of(2026,10,19,10,0);
|
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false);
|
||||||
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
|
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false);
|
||||||
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
|
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,false);
|
||||||
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
|
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,false);
|
||||||
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
|
var completed=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true);
|
||||||
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
|
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,false);
|
||||||
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
|
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
|
||||||
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
|
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
|
||||||
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
|
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
|
||||||
.contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD");
|
.contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD");
|
||||||
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
|
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
|
||||||
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
|
.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))
|
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
|
||||||
.contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD");
|
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
|
||||||
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
|
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
|
||||||
.contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD");
|
.containsExactly("SAILING_CLEARANCE");
|
||||||
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
|
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
|
||||||
.contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE");
|
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
|
||||||
assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded))
|
assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded))
|
||||||
.doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS");
|
.doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS");
|
||||||
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
|
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
|
||||||
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
|
.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,String kg,String confirmed,boolean uploaded) {
|
private WorkflowFacts.Fact fact(long id,boolean uploaded) {
|
||||||
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
|
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false,false);
|
||||||
}
|
}
|
||||||
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
|
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
|
||||||
@Test void cancellationRestoresDispatchQueue() {
|
@Test void cancellationRestoresDispatchQueue() {
|
||||||
var f=fact(1,"100",null,true);
|
var f=fact(1,true);
|
||||||
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
|
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
|
||||||
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
|
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
|
||||||
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
|
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
|
||||||
@@ -47,17 +57,10 @@ class WorkflowBusinessTest {
|
|||||||
}
|
}
|
||||||
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
|
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
|
||||||
@Test void independentSlicesAndUploadedClearance() {
|
@Test void independentSlicesAndUploadedClearance() {
|
||||||
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
|
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
|
||||||
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
|
.containsExactly("SAILING_CLEARANCE");
|
||||||
assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
|
assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
|
||||||
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
|
.containsExactly("OUTBOUND_DOCUMENT");
|
||||||
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
|
assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
|
||||||
}
|
|
||||||
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
|
|
||||||
@Test void independentWeightVersion() {
|
|
||||||
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
|
|
||||||
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
|
|
||||||
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
|
|
||||||
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-25
@@ -5,16 +5,16 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import org.mybatis.spring.SqlSessionTemplate;
|
import org.mybatis.spring.SqlSessionTemplate;
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||||
import java.nio.file.*;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
|
/** 不创建独立重量确认表,验证资料归属和最新费用版本驱动的确认流转。 */
|
||||||
class WorkflowFactPersistenceTest {
|
class WorkflowFactPersistenceTest {
|
||||||
/** 测试建表不连接业务数据库。 */
|
/** 测试建表不连接业务数据库。 */
|
||||||
@Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception {
|
@Test void clearanceAndPublishedFeeConfirmationUseCurrentFacts() throws Exception {
|
||||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||||
var db=new JdbcTemplate(ds);
|
var db=new JdbcTemplate(ds);
|
||||||
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
|
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
|
||||||
|
db.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
|
||||||
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
|
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
|
||||||
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
|
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
|
||||||
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
|
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
|
||||||
@@ -29,7 +29,6 @@ class WorkflowFactPersistenceTest {
|
|||||||
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
|
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
|
||||||
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
|
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
|
||||||
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
|
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
|
||||||
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
|
|
||||||
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
|
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
|
||||||
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
|
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
|
||||||
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
|
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
|
||||||
@@ -39,15 +38,16 @@ class WorkflowFactPersistenceTest {
|
|||||||
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
|
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
|
||||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
|
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
|
||||||
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
||||||
|
config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
|
||||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
|
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
|
||||||
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
||||||
|
var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
|
||||||
assertThat(containers.hasActualVesselDeparture(5)).isOne();
|
assertThat(containers.hasActualVesselDeparture(5)).isOne();
|
||||||
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
||||||
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
|
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
|
||||||
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
||||||
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
|
var repository=new WorkflowFactRepository(mapper);
|
||||||
assertThat(service.read(22,101)).isEmpty();
|
|
||||||
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
|
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
|
||||||
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
|
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
|
||||||
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
|
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
|
||||||
@@ -60,10 +60,11 @@ class WorkflowFactPersistenceTest {
|
|||||||
db.update("INSERT INTO fl_customs_customer_document VALUES(10,11,'ACTIVE',8)");
|
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)");
|
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();});
|
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")) {
|
for (String status : List.of("READY_TO_START", "DECLARING", "COMPLETED")) {
|
||||||
db.update("UPDATE fl_customs_container SET status=? WHERE id=8", status);
|
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).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");
|
db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10");
|
||||||
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
|
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
|
||||||
@@ -92,29 +93,42 @@ class WorkflowFactPersistenceTest {
|
|||||||
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
|
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
|
||||||
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
|
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
|
||||||
});
|
});
|
||||||
var current=service.read(11,101).get(0);
|
// 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。
|
||||||
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
|
var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class);
|
||||||
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
|
org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any()))
|
||||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
|
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false))));
|
||||||
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
var tabs=new WorkflowTabService(repository,downstream);
|
||||||
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
|
var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity();
|
||||||
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
|
inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||||
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||||
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)");
|
||||||
|
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
|
||||||
|
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||||
|
assertThat(workbench.pendingFees(11)).containsExactly(101L);
|
||||||
|
// 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。
|
||||||
|
db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20");
|
||||||
|
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"))
|
||||||
|
.noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"));
|
||||||
|
assertThat(workbench.pendingFees(11)).isEmpty();
|
||||||
|
db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)");
|
||||||
|
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||||
|
assertThat(workbench.pendingFees(11)).isEmpty();
|
||||||
|
db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22");
|
||||||
|
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
|
||||||
|
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||||
|
assertThat(workbench.pendingFees(11)).containsExactly(101L);
|
||||||
|
assertThat(workbench.pendingFees(22)).containsExactly(102L);
|
||||||
|
db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1");
|
||||||
|
assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty();
|
||||||
|
assertThat(workbench.pendingFees(11)).isEmpty();
|
||||||
|
db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1");
|
||||||
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
|
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
|
||||||
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
|
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
|
||||||
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
|
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
|
||||||
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
|
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
|
||||||
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
|
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
|
||||||
assertThat(service.read(11,101)).hasSize(1);
|
// 父单仅作汇总时,不重复进入业务页签。
|
||||||
assertThat(service.read(11,103)).hasSize(1);
|
|
||||||
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
|
|
||||||
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
|
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
|
||||||
assertThat(service.read(11,101)).isEmpty();
|
assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3));
|
||||||
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
|
||||||
var child=service.read(11,103).get(0);
|
|
||||||
assertThat(child.confirmed()).isFalse();
|
|
||||||
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
|
||||||
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ VALUES
|
|||||||
(5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,0),
|
(5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,0),
|
||||||
(5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,0),
|
(5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,0),
|
||||||
(5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,0),
|
(5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,0),
|
||||||
|
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
|
||||||
|
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
|
||||||
|
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
|
||||||
|
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
|
||||||
|
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
|
||||||
|
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0),
|
||||||
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
|
(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),
|
(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),
|
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
|
||||||
@@ -161,7 +167,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
|
|||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM iam_role role
|
FROM iam_role role
|
||||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
|
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'
|
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;
|
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))");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
||||||
|
|
||||||
|
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
|
||||||
|
|
||||||
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
||||||
|
|
||||||
沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
|
沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
|
||||||
|
|||||||
@@ -85,6 +85,8 @@
|
|||||||
|
|
||||||
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
||||||
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
||||||
|
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
||||||
|
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||||
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
||||||
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
|
|
||||||
既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
|
既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
|
||||||
|
|
||||||
|
### 自有车辆运营视图读取边界(2026-10-20)
|
||||||
|
|
||||||
|
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
|
||||||
|
|
||||||
### 平台自营统计边界
|
### 平台自营统计边界
|
||||||
|
|
||||||
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
|
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
|
|
||||||
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
|
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
|
||||||
|
|
||||||
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环、fleet不反向导入dispatch/delivery、dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去这条聚合环。
|
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。
|
||||||
|
|
||||||
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
|
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
|
|||||||
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
|
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
|
||||||
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
|
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
|
||||||
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
|
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
|
||||||
|
const ownedBusinessReadEdges = [
|
||||||
|
['model.OwnedBusinessModels', 'DispatchViews'],
|
||||||
|
['service.OwnedBusinessProjection', 'DispatchContracts'],
|
||||||
|
['service.OwnedBusinessQueryService', 'DispatchContracts'],
|
||||||
|
['service.OwnedBusinessQueryService', 'DispatchTypes'],
|
||||||
|
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
|
||||||
|
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
|
||||||
|
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
|
||||||
|
|
||||||
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
|
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
|
||||||
const report = analyze();
|
const report = analyze();
|
||||||
@@ -42,13 +50,16 @@ 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');
|
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
|
||||||
assert.equal(policyClasses.length, 3);
|
assert.equal(policyClasses.length, 3);
|
||||||
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
|
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
|
||||||
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']);
|
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']);
|
||||||
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
|
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.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.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
|
||||||
assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
||||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false);
|
&& /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.')
|
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
|
||||||
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||||
assert.ok(dispatchEdges.length > 0);
|
assert.ok(dispatchEdges.length > 0);
|
||||||
@@ -58,13 +69,54 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
|||||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
|
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
|
||||||
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|
||||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
|
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
|
||||||
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'),
|
&& 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.')}`)),
|
||||||
'dispatch must use fleet ports and guards, not its persistence mappers');
|
'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.'));
|
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||||
assert.ok(incomingEdges.length > 0);
|
assert.ok(incomingEdges.length > 0);
|
||||||
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
|
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('owned business reads only reviewed dispatch ports and cannot inherit persistence access', () => {
|
||||||
|
const report = analyze();
|
||||||
|
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||||
|
const prefix = 'com.baiyue.lastleg.fleet.ownedbusiness.';
|
||||||
|
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||||
|
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
|
||||||
|
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
|
||||||
|
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
|
||||||
|
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
|
||||||
|
const edge = { source: prefix + 'service.OwnedBusinessQueryService', target: 'com.baiyue.lastleg.dispatch.' + target };
|
||||||
|
report.classDependencies.push(edge);
|
||||||
|
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
|
||||||
|
}
|
||||||
|
const unreviewed = { source: 'com.baiyue.lastleg.fleet.trip.FleetTripService', target: 'com.baiyue.lastleg.dispatch.port.DispatchDetailReader' };
|
||||||
|
report.classDependencies.push(unreviewed);
|
||||||
|
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
|
||||||
|
const report = analyze();
|
||||||
|
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||||
|
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
|
||||||
|
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||||
|
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
|
||||||
|
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
|
||||||
|
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
|
||||||
|
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
|
||||||
|
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
|
||||||
|
report.classDependencies.push(edge);
|
||||||
|
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function analyze(sourceSet = 'main') {
|
function analyze(sourceSet = 'main') {
|
||||||
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
||||||
cwd: moduleRoot,
|
cwd: moduleRoot,
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
|
|||||||
public class DispatchAggregate {
|
public class DispatchAggregate {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String dispatchNo;
|
private String dispatchNo;
|
||||||
|
private String taskType;
|
||||||
private Long tripId;
|
private Long tripId;
|
||||||
private String overseasWarehouseId;
|
private String overseasWarehouseId;
|
||||||
private String overseasWarehouseCode;
|
private String overseasWarehouseCode;
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.fleet;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** 只读委托既有排车应用服务,运营域不引用主流程实现或持久化实体。 */
|
||||||
|
@Component
|
||||||
|
public class DispatchDetailReadAdapter implements DispatchDetailReader {
|
||||||
|
private final DispatchAggregateService dispatch;
|
||||||
|
|
||||||
|
/** 复用唯一原排车详情提供者,不建立第二套查询规则。 */
|
||||||
|
public DispatchDetailReadAdapter(DispatchAggregateService dispatch) {
|
||||||
|
this.dispatch = dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原单资格与可见性仍由原应用服务核对。 */
|
||||||
|
@Override
|
||||||
|
public DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo) {
|
||||||
|
return dispatch.detail(actor, dispatchNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-3
@@ -14,7 +14,11 @@ public interface DispatchAggregateMapper {
|
|||||||
<if test='q.dispatchNo != null'> AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%')</if>
|
<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.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.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})</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.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>
|
<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";
|
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";
|
||||||
@@ -33,12 +37,12 @@ public interface DispatchAggregateMapper {
|
|||||||
|
|
||||||
/** 首次创建整车所有权及初始版本,不写customer_id。 */
|
/** 首次创建整车所有权及初始版本,不写customer_id。 */
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||||
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
|
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,
|
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,
|
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
|
||||||
remark,created_by,created_at,updated_at)
|
remark,created_by,created_at,updated_at)
|
||||||
VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||||
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
|
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
|
||||||
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
|
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
|
||||||
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
|
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
|
||||||
|
|||||||
+34
-16
@@ -9,6 +9,21 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface DispatchCandidateMapper {
|
public interface DispatchCandidateMapper {
|
||||||
|
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||||
|
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||||
|
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||||
|
String DEPARTED_PLAN =
|
||||||
|
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||||
|
"ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" +
|
||||||
|
"SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||||
|
"ON dl.id=da.line_id LEFT JOIN ll_order departure_order " +
|
||||||
|
"ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " +
|
||||||
|
"(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" +
|
||||||
|
"CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" +
|
||||||
|
"SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " +
|
||||||
|
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
|
||||||
|
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
|
||||||
|
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
|
||||||
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
||||||
String CONFIRMED_APPOINTMENT_AT =
|
String CONFIRMED_APPOINTMENT_AT =
|
||||||
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
||||||
@@ -41,7 +56,7 @@ public interface DispatchCandidateMapper {
|
|||||||
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
|
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
|
||||||
|
|
||||||
String AVAILABLE_PLAN_BODY =
|
String AVAILABLE_PLAN_BODY =
|
||||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
|
||||||
"MIN(l.order_id) AS orderId," +
|
"MIN(l.order_id) AS orderId," +
|
||||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||||
@@ -53,16 +68,16 @@ public interface DispatchCandidateMapper {
|
|||||||
"MIN(ip.container_no) AS containerNo," +
|
"MIN(ip.container_no) AS containerNo," +
|
||||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||||
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
"CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
||||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||||
@@ -70,11 +85,11 @@ public interface DispatchCandidateMapper {
|
|||||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||||
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
||||||
@@ -108,12 +123,13 @@ public interface DispatchCandidateMapper {
|
|||||||
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
|
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
|
||||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||||
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
|
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
|
||||||
@@ -150,7 +166,7 @@ public interface DispatchCandidateMapper {
|
|||||||
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
|
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
|
||||||
|
|
||||||
String SELECTED_PLAN_SQL = "<script>" +
|
String SELECTED_PLAN_SQL = "<script>" +
|
||||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
|
||||||
"MIN(l.order_id) AS orderId," +
|
"MIN(l.order_id) AS orderId," +
|
||||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||||
@@ -161,16 +177,16 @@ public interface DispatchCandidateMapper {
|
|||||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||||
"MIN(ip.container_no) AS containerNo," +
|
"MIN(ip.container_no) AS containerNo," +
|
||||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||||
@@ -178,11 +194,11 @@ public interface DispatchCandidateMapper {
|
|||||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||||
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
||||||
@@ -216,7 +232,8 @@ public interface DispatchCandidateMapper {
|
|||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||||
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||||
@@ -261,7 +278,8 @@ public interface DispatchCandidateMapper {
|
|||||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
"<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='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>" +
|
"<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 " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||||
"ON existing_dispatch.id=di.dispatch_order_id " +
|
"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 " +
|
"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) { }
|
long allocatedPackages, long pickedPackages, long version) { }
|
||||||
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
|
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
|
||||||
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
|
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 Claim(Long id, Long dispatchId, String draftToken) { }
|
||||||
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
|
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
|
||||||
record NativeApplication(String customerId,String relabelNo) { }
|
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")
|
@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);
|
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} "
|
@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}")
|
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.port;
|
||||||
|
|
||||||
|
/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */
|
||||||
|
public interface DispatchDetailReader {
|
||||||
|
/** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */
|
||||||
|
DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo);
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ public final class DispatchViews {
|
|||||||
public record Page(List<ListRow> records, long total, long page, long size, long pages,
|
public record Page(List<ListRow> records, long total, long page, long size, long pages,
|
||||||
Statistics statistics, Instant snapshotAt) { }
|
Statistics statistics, Instant snapshotAt) { }
|
||||||
|
|
||||||
public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion,
|
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||||
ReviewState reviewState, Reviews reviews, Resources resources,
|
ReviewState reviewState, Reviews reviews, Resources resources,
|
||||||
String warehouseId, String warehouseCode, String warehouseName,
|
String warehouseId, String warehouseCode, String warehouseName,
|
||||||
Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
Instant plannedDepartureAt, Instant departedAt, Counts counts,
|
||||||
@@ -56,7 +56,7 @@ public final class DispatchViews {
|
|||||||
String sourceBusinessType, String sourceBusinessNo) { }
|
String sourceBusinessType, String sourceBusinessNo) { }
|
||||||
|
|
||||||
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
|
/** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */
|
||||||
public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion,
|
public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
|
||||||
ReviewState reviewState, Reviews reviews, Counts counts,
|
ReviewState reviewState, Reviews reviews, Counts counts,
|
||||||
boolean pickingReconciled, Map<Action, Gate> actions,
|
boolean pickingReconciled, Map<Action, Gate> actions,
|
||||||
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
|
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.repository;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
|
||||||
|
@Repository
|
||||||
|
public class DispatchNumberSequence {
|
||||||
|
private final DispatchNumberSequenceMapper mapper;
|
||||||
|
|
||||||
|
/** 流水与排车主单使用相同服务数据库。 */
|
||||||
|
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
|
||||||
|
|
||||||
|
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public long next(LocalDate date) {
|
||||||
|
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
|
||||||
|
mapper.allocate(date);
|
||||||
|
Long value = mapper.allocatedValue();
|
||||||
|
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
|
||||||
|
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.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 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); }
|
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
|
||||||
/** 锁定当前活动资源占用。 */
|
/** 锁定当前活动资源占用。 */
|
||||||
|
|||||||
+27
-3
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
|||||||
import com.baiyue.lastleg.dispatch.port.*;
|
import com.baiyue.lastleg.dispatch.port.*;
|
||||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
|
||||||
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
|
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
|
||||||
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
|
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
@@ -31,6 +32,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
|||||||
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
||||||
@Service
|
@Service
|
||||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
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 DispatchAppointmentAllocations appointments;
|
||||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||||
@@ -47,6 +50,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
private final ObjectMapper json;
|
private final ObjectMapper json;
|
||||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
|
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
|
||||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
|
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
|
||||||
|
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
|
||||||
|
|
||||||
/** 领域编排只访问Repository和提供者应用入口。 */
|
/** 领域编排只访问Repository和提供者应用入口。 */
|
||||||
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
|
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
|
||||||
@@ -146,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("计划筛选不合法");
|
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()),
|
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
||||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
|
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
|
||||||
|
long started=System.nanoTime();
|
||||||
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
|
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
|
||||||
|
long candidatesRead=System.nanoTime();
|
||||||
var groups=sourceRules.selectionGroups(rows);
|
var groups=sourceRules.selectionGroups(rows);
|
||||||
|
long groupsRead=System.nanoTime();
|
||||||
var batchFacts=appointments.candidates(rows);
|
var batchFacts=appointments.candidates(rows);
|
||||||
|
long batchesRead=System.nanoTime();
|
||||||
var result=new ArrayList<DispatchPlanningModels.Candidate>();
|
var result=new ArrayList<DispatchPlanningModels.Candidate>();
|
||||||
for(var row:rows) {
|
for(var row:rows) {
|
||||||
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
|
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
|
||||||
@@ -178,6 +186,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
|
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));
|
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);
|
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +281,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
var replay = commands.replay(actor,null,identity);
|
var replay = commands.replay(actor,null,identity);
|
||||||
if (replay.isPresent()) return replay.get();
|
if (replay.isPresent()) return replay.get();
|
||||||
long id=IdWorker.getId();
|
long id=IdWorker.getId();
|
||||||
String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
|
String number=numbers.next();
|
||||||
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
|
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
|
||||||
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
|
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
|
||||||
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
|
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
|
||||||
@@ -522,6 +536,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
|
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
|
||||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
|
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
|
||||||
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
|
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
|
||||||
|
// 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。
|
||||||
|
// 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。
|
||||||
|
ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(),
|
||||||
|
context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at);
|
||||||
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
|
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,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.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();
|
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());
|
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
|
||||||
return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
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()),
|
||||||
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
|
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),
|
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
|
||||||
documents,orderDetails(cargo));
|
documents,orderDetails(cargo));
|
||||||
@@ -747,6 +765,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
|
|
||||||
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
|
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
|
||||||
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
|
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());
|
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());
|
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
|
||||||
for(var action:Action.values()) {
|
for(var action:Action.values()) {
|
||||||
@@ -800,7 +824,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
|
|
||||||
/** 列表不逐行读取明细、附件或派送。 */
|
/** 列表不逐行读取明细、附件或派送。 */
|
||||||
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
|
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
|
||||||
return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
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()),
|
||||||
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));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */
|
||||||
|
@Component
|
||||||
|
public class DispatchNumberGenerator {
|
||||||
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private final DispatchNumberSequence sequences;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
/** 正式运行按统一业务时区取当天日期。 */
|
||||||
|
@Autowired
|
||||||
|
public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); }
|
||||||
|
|
||||||
|
/** 固定时钟用于核实跨午夜归零边界。 */
|
||||||
|
DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; }
|
||||||
|
|
||||||
|
/** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */
|
||||||
|
public String next() {
|
||||||
|
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
|
||||||
|
long value = sequences.next(date);
|
||||||
|
if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内");
|
||||||
|
return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.DispatchCandidateMapper;
|
||||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
||||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
|
||||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -34,16 +35,64 @@ public class DispatchService {
|
|||||||
|
|
||||||
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
|
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
|
||||||
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
|
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
|
||||||
|
var reads=new CandidateReadScope(rows);
|
||||||
var result=new LinkedHashMap<String,SelectionGroup>();
|
var result=new LinkedHashMap<String,SelectionGroup>();
|
||||||
for(var row:rows) {
|
for(var row:rows) {
|
||||||
String key=wholeSourceKey(selection(row,null));
|
String key=wholeSourceKey(selection(row,null));
|
||||||
if(result.containsKey(key))continue;
|
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());
|
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
|
||||||
}
|
}
|
||||||
return Collections.unmodifiableMap(result);
|
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) {
|
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
|
||||||
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
|
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
|
||||||
@@ -78,6 +127,11 @@ public class DispatchService {
|
|||||||
|
|
||||||
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
|
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
|
||||||
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
|
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 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 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>();
|
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,"实托完整来源快照缺失");
|
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
|
||||||
reasons.addAll(facts.blockReasons());
|
reasons.addAll(facts.blockReasons());
|
||||||
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
|
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());
|
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
|
||||||
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
|
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());
|
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+":")));
|
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
|
||||||
if(!pallets.isEmpty()) {
|
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("实托可用来源超过完整核验上限");
|
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()));
|
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()) {
|
for(var entry:buckets.entrySet()) {
|
||||||
@@ -130,7 +187,8 @@ public class DispatchService {
|
|||||||
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
|
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));
|
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 versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
|
||||||
var sources=new ArrayList<SelectionGroupSource>();
|
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>();
|
var facts = new java.util.TreeMap<String,Object>();
|
||||||
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
|
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
|
||||||
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
|
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("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
|
||||||
facts.put("cartonMarks",row.relabelCartonMarks());
|
facts.put("cartonMarks",row.relabelCartonMarks());
|
||||||
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
|
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())))
|
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
|
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
|
||||||
facts.put("stock",stock);
|
facts.put("stock",stock);
|
||||||
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
|
facts.put("receipts",basis.receipts());
|
||||||
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
facts.put("returnedReceipts",basis.returnedReceipts());
|
||||||
return wholeHashes.source(facts);
|
return wholeHashes.source(facts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
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
@@ -0,0 +1,42 @@
|
|||||||
|
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
@@ -0,0 +1,23 @@
|
|||||||
|
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
@@ -0,0 +1,84 @@
|
|||||||
|
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
@@ -0,0 +1,34 @@
|
|||||||
|
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
@@ -0,0 +1,158 @@
|
|||||||
|
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
@@ -0,0 +1,29 @@
|
|||||||
|
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
@@ -0,0 +1,28 @@
|
|||||||
|
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
@@ -0,0 +1,52 @@
|
|||||||
|
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); }
|
||||||
|
}
|
||||||
+10
-4
@@ -20,6 +20,7 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
|
|||||||
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
|
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
|
||||||
@Service
|
@Service
|
||||||
public class FleetPoolOrderBridge {
|
public class FleetPoolOrderBridge {
|
||||||
|
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
|
||||||
private final FleetPoolOrderRepository repository;
|
private final FleetPoolOrderRepository repository;
|
||||||
private final FleetPoolOrderNumberGenerator numbers;
|
private final FleetPoolOrderNumberGenerator numbers;
|
||||||
private final ObjectMapper json;
|
private final ObjectMapper json;
|
||||||
@@ -47,16 +48,20 @@ public class FleetPoolOrderBridge {
|
|||||||
validate(in);
|
validate(in);
|
||||||
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
|
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
|
||||||
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
|
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();
|
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
|
||||||
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
|
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
|
||||||
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId());
|
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId());
|
||||||
row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()));
|
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo());
|
||||||
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
|
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
|
||||||
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
|
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
|
||||||
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
|
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
|
||||||
row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now);
|
row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now);
|
||||||
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
|
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
|
||||||
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
|
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())) {
|
||||||
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
|
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
|
||||||
order.setTransportType(transport);
|
order.setTransportType(transport);
|
||||||
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
|
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
|
||||||
@@ -79,7 +84,7 @@ public class FleetPoolOrderBridge {
|
|||||||
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
|
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.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
|
||||||
}
|
}
|
||||||
repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
|
repository.insertLink(row);history(row,source==null?null:"PENDING_ACCEPTANCE",row.getFulfillmentStage(),operator,source==null?"确认承运并建立商业委托":"原单匹配车次并确认承运",now);
|
||||||
return ref(row);
|
return ref(row);
|
||||||
}
|
}
|
||||||
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
|
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
|
||||||
@@ -177,6 +182,7 @@ public class FleetPoolOrderBridge {
|
|||||||
/** 批量读取预订的业务编号。 */
|
/** 批量读取预订的业务编号。 */
|
||||||
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
|
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
|
||||||
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
|
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;
|
repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
|
||||||
}
|
}
|
||||||
static void validate(ConfirmedBooking in) {
|
static void validate(ConfirmedBooking in) {
|
||||||
|
|||||||
+3
-1
@@ -38,7 +38,9 @@ public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
|
|||||||
FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no);
|
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")
|
@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);
|
List<FleetPoolOrderLink> lockDispatch(@Param("customerId") String customerId,@Param("no") String no);
|
||||||
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}")
|
/** 原单锁内使用当前读,避免等待匹配提交后仍按旧快照放行独立修改。 */
|
||||||
|
@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)")
|
||||||
int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long 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}")
|
@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);
|
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());}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/** 只读原排车装车后事实,仅附加登记和照片可写。 */
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg/fleet/owned-business")
|
||||||
|
public class OwnedBusinessController {
|
||||||
|
private final OwnedBusinessQueryService queries;
|
||||||
|
private final OwnedBusinessRecordService records;
|
||||||
|
private final OwnedBusinessPhotoService photos;
|
||||||
|
private final OwnedBusinessSupport access;
|
||||||
|
/** Controller不直调Repository/Mapper,身份只取会话。 */
|
||||||
|
public OwnedBusinessController(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
|
||||||
|
this.queries=queries;this.records=records;this.photos=photos;this.access=access;
|
||||||
|
}
|
||||||
|
/** 列表和完整统计一次返回。 */
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
|
||||||
|
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(queries.list(access.actor(user),query));}
|
||||||
|
/** list权限包含独立详情。 */
|
||||||
|
@GetMapping("/{dispatchNo}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
|
||||||
|
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(queries.detail(access.actor(user),dispatchNo));}
|
||||||
|
/** 原排车和附加历史在同一快照分页,不逐条GET。 */
|
||||||
|
@GetMapping("/{dispatchNo}/history")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
|
||||||
|
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||||
|
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
|
||||||
|
return ApiResponse.success(queries.history(access.actor(user),dispatchNo,page,size));
|
||||||
|
}
|
||||||
|
/** 金额可以更正/清空,但不推进主单或触发财务。 */
|
||||||
|
@PutMapping("/{dispatchNo}/operations")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:edit')")
|
||||||
|
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
|
||||||
|
return ApiResponse.success(records.save(access.actor(user),dispatchNo,request));
|
||||||
|
}
|
||||||
|
/** 上传仅准备File,关联保存另有独立原键。 */
|
||||||
|
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
|
||||||
|
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||||
|
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||||
|
return ApiResponse.success(photos.upload(access.actor(user),dispatchNo,slot,idempotencyKey,file));
|
||||||
|
}
|
||||||
|
/** 绑定失败保留fileId和原请求,不重新上传。 */
|
||||||
|
@PutMapping("/{dispatchNo}/photos/{slot}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
|
||||||
|
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||||
|
return ApiResponse.success(photos.bind(access.actor(user),dispatchNo,slot,request));
|
||||||
|
}
|
||||||
|
/** 附件只允许当前或精确历史引用读取。 */
|
||||||
|
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
|
||||||
|
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||||
|
@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
|
||||||
|
var result=photos.content(access.actor(user),dispatchNo,slot,fileId,revisionVersion);
|
||||||
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
|
||||||
|
.header("X-Content-Type-Options","nosniff").body(result.content());
|
||||||
|
}
|
||||||
|
/** 不自动补写或向别的账号披露原命令。 */
|
||||||
|
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
|
||||||
|
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||||
|
return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
||||||
|
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
|
||||||
|
/** File保存照片,本域另行授权原排车及分类引用。 */
|
||||||
|
@Component
|
||||||
|
public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
|
||||||
|
public static final long MAX_SIZE=10L*1024*1024;
|
||||||
|
private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}";
|
||||||
|
private final RestClient client;
|
||||||
|
private final String token;
|
||||||
|
/** 沿既有内部File客户端,保持外部访问域封闭。 */
|
||||||
|
public HttpOwnedBusinessFileGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties,
|
||||||
|
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
|
||||||
|
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||||
|
client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token;
|
||||||
|
}
|
||||||
|
/** MIME和字节签名一起校验,File仍执行其权威校验。 */
|
||||||
|
public static void validate(Upload upload) {
|
||||||
|
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|
||||||
|
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|
||||||
|
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content()))
|
||||||
|
throw bad("照片须为非空真实JPG/PNG,最大10MiB");
|
||||||
|
}
|
||||||
|
/** 使用原图片字节,未知结果不能换键或自动重传。 */
|
||||||
|
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
|
||||||
|
validate(upload);var body=new MultipartBodyBuilder();
|
||||||
|
body.part("file",new ByteArrayResource(upload.content()){
|
||||||
|
/** 保留本轮原文件名,不生成占位证据。 */
|
||||||
|
@Override public String getFilename(){return upload.fileName();}
|
||||||
|
}).contentType(MediaType.parseMediaType(upload.contentType()));
|
||||||
|
var result=client.post().uri(PATH,slot.businessType(),dispatchId).header("X-Internal-Service-Token",token)
|
||||||
|
.header("X-Operator-Id",operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().body(Envelope.class);
|
||||||
|
return verified(result,dispatchId,slot,null);
|
||||||
|
}
|
||||||
|
/** 不能借另一客户、另一分类或另一排车的文件授权。 */
|
||||||
|
@Override public Evidence metadata(long dispatchId,PhotoSlot slot,long fileId) {
|
||||||
|
return verified(client.get().uri(PATH+"/{file}",slot.businessType(),dispatchId,fileId)
|
||||||
|
.header("X-Internal-Service-Token",token).retrieve().body(Envelope.class),dispatchId,slot,fileId);
|
||||||
|
}
|
||||||
|
/** 防止把错误页或其他内容冒充图片返回。 */
|
||||||
|
@Override public byte[] content(long dispatchId,PhotoSlot slot,long fileId) {
|
||||||
|
var info=metadata(dispatchId,slot,fileId);
|
||||||
|
byte[] bytes=client.get().uri(PATH+"/{file}/content",slot.businessType(),dispatchId,fileId)
|
||||||
|
.header("X-Internal-Service-Token",token).retrieve().body(byte[].class);
|
||||||
|
if(bytes==null || bytes.length!=info.sizeBytes() || !signature(info.contentType(),bytes))throw conflict("图片内容与元数据不一致");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
/** 所有归属及内容元数据必须严格匹配原dispatch内部ID。 */
|
||||||
|
public static Evidence verified(Envelope response,long dispatchId,PhotoSlot slot,Long expected) {
|
||||||
|
if(response==null || !"200".equals(response.code()) || response.data()==null)throw conflict("照片服务未返回明确成功");
|
||||||
|
var m=response.data();
|
||||||
|
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|
||||||
|
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|
||||||
|
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType())
|
||||||
|
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
|
||||||
|
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
|
||||||
|
}
|
||||||
|
/** 不放开PDF、SVG或任意image类型。 */
|
||||||
|
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);}
|
||||||
|
/** 使用真实文件头,不转换或伪造证据图片。 */
|
||||||
|
private static boolean signature(String type,byte[] bytes) {
|
||||||
|
if(bytes==null)return false;
|
||||||
|
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
|
||||||
|
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
|
||||||
|
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
|
||||||
|
}
|
||||||
|
/** 基础设施错误不反向依赖应用Service,保持单向包依赖。 */
|
||||||
|
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
|
||||||
|
/** 不明确的文件结果保留为业务冲突,由上传账本记录。 */
|
||||||
|
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
|
||||||
|
public record Envelope(String code,Metadata data) {}
|
||||||
|
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes,LocalDateTime createdAt) {}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||||
|
|
||||||
|
/** 本页平台图片受控代理,不接收浏览器指定的内部主键或业务类型。 */
|
||||||
|
public interface OwnedBusinessFileGateway {
|
||||||
|
record Upload(String fileName,String contentType,byte[] content) {}
|
||||||
|
record Evidence(long fileId,String fileName,String contentType,long sizeBytes,String uploadedAt) {}
|
||||||
|
/** 同一原意图只上传一次,未知结果由账本保存。 */
|
||||||
|
Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload);
|
||||||
|
/** 核验平台、原排车、分类、文件身份和图像格式。 */
|
||||||
|
Evidence metadata(long dispatchId,PhotoSlot slot,long fileId);
|
||||||
|
/** 只代理业务层已确认当前或历史引用的内容。 */
|
||||||
|
byte[] content(long dispatchId,PhotoSlot slot,long fileId);
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 本页4表唯一SQL入口;不更新排车、库存或财务表。 */
|
||||||
|
@Mapper
|
||||||
|
public interface OwnedBusinessMapper {
|
||||||
|
/** 读取已经冻结的装车归属,不查询当前车辆推断历史。 */
|
||||||
|
@Select("SELECT * FROM ll_fleet_owned_business_ownership WHERE dispatch_id=#{id}")
|
||||||
|
Ownership ownership(long id);
|
||||||
|
/** 在主装车事务保存唯一归属证据,重复交由提供者核对原事实。 */
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO ll_fleet_owned_business_ownership(dispatch_id,vehicle_id,resource_mode,vehicle_ownership,
|
||||||
|
vehicle_record_version,ownership_captured_at,loading_completion_hash,loading_completed_at)
|
||||||
|
VALUES(#{dispatchId},#{vehicleId},#{resourceMode},#{vehicleOwnership},#{vehicleRecordVersion},
|
||||||
|
#{ownershipCapturedAt},#{loadingCompletionHash},#{loadingCompletedAt})
|
||||||
|
""")
|
||||||
|
int capture(Ownership row);
|
||||||
|
/** GET不使用此方法;首个写事务建立可锁定的附加0版本行。 */
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO ll_fleet_owned_business_record(id,dispatch_id,record_version,arrivals_json,photos_json,
|
||||||
|
created_by,updated_by,created_at,updated_at) VALUES(#{id},#{dispatchId},0,'[]','[]',
|
||||||
|
#{createdBy},#{updatedBy},#{createdAt},#{updatedAt}) ON DUPLICATE KEY UPDATE dispatch_id=dispatch_id
|
||||||
|
""")
|
||||||
|
int ensure(Record row);
|
||||||
|
/** 只锁本页附加行,不反向申请车辆或派送写锁。 */
|
||||||
|
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id} FOR UPDATE")
|
||||||
|
Record lockRecord(long id);
|
||||||
|
/** 详情只读附加信息,无记录返回虚拟0版本。 */
|
||||||
|
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id}")
|
||||||
|
Record record(long id);
|
||||||
|
/** 页内批量读取,避免逐车补充GET和SQL。 */
|
||||||
|
@Select("<script>SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||||
|
List<Record> records(@Param("ids") List<Long> ids);
|
||||||
|
/** CAS仅修改运营附加字段,不占用主单recordVersion。 */
|
||||||
|
@Update("""
|
||||||
|
UPDATE ll_fleet_owned_business_record SET record_version=#{r.recordVersion},currency=#{r.currency},
|
||||||
|
total_amount=#{r.totalAmount},actual_distance_miles=#{r.actualDistanceMiles},
|
||||||
|
fuel_consumption_litres_per_100km=#{r.fuelConsumptionLitresPer100Km},arrivals_json=#{r.arrivalsJson},
|
||||||
|
photos_json=#{r.photosJson},updated_by=#{r.updatedBy},updated_at=#{r.updatedAt}
|
||||||
|
WHERE dispatch_id=#{r.dispatchId} AND record_version=#{expected}
|
||||||
|
""")
|
||||||
|
int update(@Param("r") Record row,@Param("expected") long expected);
|
||||||
|
/** 命令键全车唯一,操作者在服务端另行核验。 */
|
||||||
|
@Select("SELECT * FROM ll_fleet_owned_business_command WHERE dispatch_id=#{id} AND idempotency_key=#{key}")
|
||||||
|
Command command(@Param("id") long id,@Param("key") String key);
|
||||||
|
/** 完整意图在文件HTTP之前单独事务提交。 */
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO ll_fleet_owned_business_command(id,dispatch_id,operator_id,idempotency_key,action,
|
||||||
|
expected_version,request_hash,request_json,status,result_json,error_code,created_at,updated_at)
|
||||||
|
VALUES(#{id},#{dispatchId},#{operatorId},#{idempotencyKey},#{action},#{expectedVersion},
|
||||||
|
#{requestHash},#{requestJson},#{status},#{resultJson},#{errorCode},#{createdAt},#{updatedAt})
|
||||||
|
""")
|
||||||
|
int insertCommand(Command row);
|
||||||
|
/** 上传只完成原待定意图,不能把未知结果当新命令重做。 */
|
||||||
|
@Update("""
|
||||||
|
UPDATE ll_fleet_owned_business_command SET status=#{status},result_json=#{resultJson},
|
||||||
|
error_code=#{errorCode},updated_at=#{updatedAt} WHERE id=#{id} AND status='PENDING'
|
||||||
|
""")
|
||||||
|
int finishUpload(Command row);
|
||||||
|
/** 附加写与不可变历史同事务,版本不能重复。 */
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO ll_fleet_owned_business_history(id,dispatch_id,record_version,action,operator_id,
|
||||||
|
reason,snapshot_json,occurred_at) VALUES(#{id},#{dispatchId},#{recordVersion},#{action},#{operatorId},
|
||||||
|
#{reason},#{snapshotJson},#{occurredAt})
|
||||||
|
""")
|
||||||
|
int insertHistory(History row);
|
||||||
|
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
|
||||||
|
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||||
|
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||||
|
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||||
|
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||||
|
UNION ALL
|
||||||
|
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
|
||||||
|
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||||
|
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||||
|
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||||
|
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||||
|
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
|
||||||
|
""")
|
||||||
|
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
|
||||||
|
/** 历史总数不取当前页长度。 */
|
||||||
|
@Select("SELECT (SELECT COUNT(*) FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id})+(SELECT COUNT(*) FROM ll_dispatch_history WHERE dispatch_id=#{id})")
|
||||||
|
int historyCount(long id);
|
||||||
|
/** 历史文件只能从指定真实历史快照读取。 */
|
||||||
|
@Select("SELECT * FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} AND record_version=#{version}")
|
||||||
|
History revision(@Param("id") long id,@Param("version") long version);
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
|
||||||
|
@Mapper
|
||||||
|
public interface OwnedBusinessQueryMapper {
|
||||||
|
String SOURCE="""
|
||||||
|
SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
|
||||||
|
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
|
||||||
|
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
|
||||||
|
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
|
||||||
|
o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version,
|
||||||
|
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
|
||||||
|
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
|
||||||
|
""";
|
||||||
|
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
|
||||||
|
String FILTER="""
|
||||||
|
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
|
||||||
|
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
|
||||||
|
AND o.vehicle_ownership=#{q.scope.ownership}
|
||||||
|
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
|
||||||
|
AND o.loading_completed_at=d.loading_completed_at
|
||||||
|
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
|
||||||
|
<if test='q.from != null'> AND d.planned_departure_at >= #{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>
|
||||||
|
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
|
||||||
|
<if test='q.keyword != null'> AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||||
|
OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i
|
||||||
|
WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
|
||||||
|
OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
|
||||||
|
OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%'))))</if>
|
||||||
|
""";
|
||||||
|
/** 只返回当前完整条件下的一车一行。 */
|
||||||
|
@Select("<script>"+SOURCE+" WHERE "+FILTER+" ORDER BY d.planned_departure_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
|
||||||
|
List<Source> page(@Param("q") Search query);
|
||||||
|
/** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */
|
||||||
|
@Select("<script>SELECT d.stage,COUNT(*) AS quantity FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id WHERE "+FILTER+" GROUP BY d.stage</script>")
|
||||||
|
List<StageCount> summary(@Param("q") Search query);
|
||||||
|
/** 先读原主单和可空归属,以明确区别无快照与不存在。 */
|
||||||
|
@Select(SOURCE+" WHERE d.dispatch_no=#{number}")
|
||||||
|
Source find(String number);
|
||||||
|
/** 页内全部货物一次读取,各客户保持原归属。 */
|
||||||
|
@Select("<script>SELECT id,dispatch_order_id,warehouse_pallet_id,customer_id,customer_name,source_key,order_no,inbound_code,pallet_no,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,id</script>")
|
||||||
|
List<CargoRow> cargo(@Param("ids") List<Long> ids);
|
||||||
|
/** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */
|
||||||
|
@Select("""
|
||||||
|
<script>SELECT a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,
|
||||||
|
p.appointment_start_utc AS start_utc,SUM(a.package_count) AS allocated_packages
|
||||||
|
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_item i
|
||||||
|
ON i.id=a.dispatch_item_id AND i.dispatch_order_id=a.dispatch_id AND i.customer_id=a.customer_id
|
||||||
|
JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
|
||||||
|
WHERE a.dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||||
|
GROUP BY a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,p.appointment_start_utc
|
||||||
|
ORDER BY a.dispatch_id,a.destination_key,p.appointment_no</script>
|
||||||
|
""")
|
||||||
|
List<Batch> batches(@Param("ids") List<Long> ids);
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.model;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 原排车只读投影和附加信息,不公开第二套履约命令。 */
|
||||||
|
public final class OwnedBusinessModels {
|
||||||
|
/** 工具容器不允许实例化。 */
|
||||||
|
private OwnedBusinessModels() {}
|
||||||
|
public static final String LIST="platform:fleet:owned-business:list";
|
||||||
|
public static final String EDIT="platform:fleet:owned-business:edit";
|
||||||
|
public static final String UPLOAD="platform:fleet:owned-business:upload";
|
||||||
|
public enum Stage { AFTER_LOADING, DELIVERING, DELIVERED }
|
||||||
|
public enum PhotoSlot {
|
||||||
|
LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT, FUEL_START, FUEL_END;
|
||||||
|
/** 图片框分类不表示司机审核或真实派送签收。 */
|
||||||
|
public String category() { return name().startsWith("FUEL_")?"FUEL":"LOADING"; }
|
||||||
|
/** 文件类型与租户归属由本域固定,浏览器不能指定。 */
|
||||||
|
public String businessType() { return category().equals("FUEL")?"OVERSEAS_DISPATCH_FUEL_PHOTO":"OVERSEAS_DISPATCH_LOADING_PHOTO"; }
|
||||||
|
}
|
||||||
|
public record Actor(String operatorId,String operatorName,Set<String> permissions) {}
|
||||||
|
public record Query(@Min(1) Integer page,@Min(1) @Max(200) Integer size,
|
||||||
|
String plannedDepartureFrom,String plannedDepartureTo,@Size(max=128) String keyword,
|
||||||
|
Stage stage,@Size(max=64) String warehouseId) {}
|
||||||
|
public record Money(@NotBlank @Pattern(regexp="CNY|USD") String currency,
|
||||||
|
@NotBlank @Pattern(regexp="(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?") String amount) {}
|
||||||
|
public record Arrival(@NotBlank @Size(max=256) String destinationKey,String actualArrivedAt) {}
|
||||||
|
public record Operations(@Valid Money money,String actualDistanceMiles,
|
||||||
|
String fuelConsumptionLitresPer100Km,@NotNull List<@NotNull @Valid Arrival> arrivals) {}
|
||||||
|
public record SaveOperations(@NotBlank String recordVersion,
|
||||||
|
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
|
||||||
|
@Size(max=1000) String reason) {}
|
||||||
|
public record BindPhoto(@NotBlank String recordVersion,
|
||||||
|
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
|
||||||
|
public record Gate(boolean allowed,String blockReason) {}
|
||||||
|
public record Actions(Gate edit,Gate upload) {}
|
||||||
|
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
|
||||||
|
public record Vehicle(String vehicleId,String plate,String ownership) {}
|
||||||
|
public record Driver(String name,String phone) {}
|
||||||
|
public record Appointment(String appointmentNo,String confirmationNo,String scheduledAt,String allocatedPackages) {}
|
||||||
|
public record Destination(String destinationKey,String warehouseCode,String warehouseName,String address,
|
||||||
|
List<Appointment> appointments,String actualArrivedAt) {}
|
||||||
|
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,
|
||||||
|
String sizeBytes,String uploadedAt,String savedAt) {}
|
||||||
|
public record PhotoSummary(int loadingCount,int fuelCount) {}
|
||||||
|
public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt,
|
||||||
|
String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver,
|
||||||
|
List<Destination> destinations,Integer actualPalletCount,Operations operations,String unitPrice,
|
||||||
|
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues) {}
|
||||||
|
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
|
||||||
|
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
|
||||||
|
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
|
||||||
|
String inboundCode,String palletNo,String destinationKey,String packages) {}
|
||||||
|
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
|
||||||
|
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
|
||||||
|
Operations operations,List<Photo> photos) {}
|
||||||
|
public record HistoryPage(List<History> records,int page,int size,int total) {}
|
||||||
|
public record Command(String dispatchNo,String action,String idempotencyKey,String state,
|
||||||
|
String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
|
||||||
|
public record Snapshot(Operations operations,List<Photo> photos,String operatorName) {}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 仅供本包持久化,不向浏览器输出内部主键。 */
|
||||||
|
public final class OwnedBusinessRows {
|
||||||
|
/** 持久化容器不允许实例化。 */
|
||||||
|
private OwnedBusinessRows() {}
|
||||||
|
@Data public static class Ownership {
|
||||||
|
private Long dispatchId,vehicleId,vehicleRecordVersion;
|
||||||
|
private String resourceMode,vehicleOwnership,loadingCompletionHash;
|
||||||
|
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
|
||||||
|
}
|
||||||
|
@Data public static class Source {
|
||||||
|
private Long id,recordVersion;
|
||||||
|
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
|
||||||
|
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
|
||||||
|
private LocalDateTime plannedDepartureAt,loadingCompletedAt,departedAt,deliveryCompletedAt;
|
||||||
|
private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash;
|
||||||
|
private Long ownedVehicleId,ownedVehicleRecordVersion;
|
||||||
|
private LocalDateTime ownedLoadingCompletedAt;
|
||||||
|
}
|
||||||
|
/** 原表货物只读字段;不是可写排车实体,不复制来源状态机。 */
|
||||||
|
@Data public static class CargoRow {
|
||||||
|
private Long id,dispatchOrderId,warehousePalletId;
|
||||||
|
private String customerId,customerName,sourceKey,orderNo,inboundCode,palletNo;
|
||||||
|
private String destinationType,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,deliveryAddress,postalCode;
|
||||||
|
private Integer packageCount;
|
||||||
|
}
|
||||||
|
@Data public static class Record {
|
||||||
|
private Long id,dispatchId,recordVersion;
|
||||||
|
private String currency,arrivalsJson,photosJson,createdBy,updatedBy;
|
||||||
|
private BigDecimal totalAmount,actualDistanceMiles,fuelConsumptionLitresPer100Km;
|
||||||
|
private LocalDateTime createdAt,updatedAt;
|
||||||
|
}
|
||||||
|
@Data public static class Command {
|
||||||
|
private Long id,dispatchId,expectedVersion;
|
||||||
|
private String operatorId,idempotencyKey,action,requestHash,requestJson,status,resultJson,errorCode;
|
||||||
|
private LocalDateTime createdAt,updatedAt;
|
||||||
|
}
|
||||||
|
@Data public static class History {
|
||||||
|
private Long id,dispatchId,recordVersion,dispatchRecordVersion;
|
||||||
|
private String eventSource,action,operatorId,reason,snapshotJson;
|
||||||
|
private LocalDateTime occurredAt;
|
||||||
|
}
|
||||||
|
@Data public static class Batch {
|
||||||
|
private Long dispatchId;
|
||||||
|
private String destinationKey,appointmentNo,confirmationNo;
|
||||||
|
private LocalDateTime startUtc;
|
||||||
|
private Long allocatedPackages;
|
||||||
|
}
|
||||||
|
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
|
||||||
|
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
|
||||||
|
@Data public static class StageCount { private String stage;private Long quantity; }
|
||||||
|
}
|
||||||
+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?"自有":"外配")+"业务");}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.port;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** 装车主事务唯一捕获车辆归属,不创建第二套履约状态。 */
|
||||||
|
public interface OwnedBusinessOwnershipCapture {
|
||||||
|
/** 主流程已稳定车辆资格,仅捕获原装车时点的不可变归属。 */
|
||||||
|
void capture(long dispatchId, Long vehicleId, String resourceMode,
|
||||||
|
String loadingCompletionHash, Instant loadingCompletedAt);
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.repository;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 只封装本页Mapper,关系库访问不使用JdbcTemplate或直连JDBC。 */
|
||||||
|
@Repository
|
||||||
|
public class OwnedBusinessRepository {
|
||||||
|
private final OwnedBusinessMapper records;
|
||||||
|
private final OwnedBusinessQueryMapper queries;
|
||||||
|
/** 注入唯一SQL提供者,Service不能直接访问Mapper。 */
|
||||||
|
public OwnedBusinessRepository(OwnedBusinessMapper records,OwnedBusinessQueryMapper queries){this.records=records;this.queries=queries;}
|
||||||
|
/** 原归属已捕获后不按当前车辆回算。 */
|
||||||
|
public Ownership ownership(long id){return records.ownership(id);}
|
||||||
|
/** 归属与主装车事务共同提交。 */
|
||||||
|
public void capture(Ownership row){records.capture(row);}
|
||||||
|
/** 服务端整车分页。 */
|
||||||
|
public List<Source> page(Search query){return queries.page(query);}
|
||||||
|
/** 与分页相同过滤的完整统计。 */
|
||||||
|
public List<StageCount> summary(Search query){return queries.summary(query);}
|
||||||
|
/** 读取唯一原排车身份及归属证据。 */
|
||||||
|
public Source source(String number){return queries.find(number);}
|
||||||
|
/** 页内货物批量查询。 */
|
||||||
|
public List<CargoRow> cargo(List<Long> ids){return ids.isEmpty()?List.of():queries.cargo(ids);}
|
||||||
|
/** 页内实际预约分配批量查询。 */
|
||||||
|
public List<Batch> batches(List<Long> ids){return ids.isEmpty()?List.of():queries.batches(ids);}
|
||||||
|
/** GET只读,无记录保留虚拟0版本。 */
|
||||||
|
public Record record(long id){return records.record(id);}
|
||||||
|
/** 页内附加信息批量读取。 */
|
||||||
|
public List<Record> records(List<Long> ids){return ids.isEmpty()?List.of():records.records(ids);}
|
||||||
|
/** 首个写事务建立行,并按dispatch唯一键串行。 */
|
||||||
|
public Record lock(Record seed){records.ensure(seed);return records.lockRecord(seed.getDispatchId());}
|
||||||
|
/** 只CAS修改附加信息。 */
|
||||||
|
public int update(Record row,long expected){return records.update(row,expected);}
|
||||||
|
/** 命令先按全车请求键定位,再在服务端检查原操作者。 */
|
||||||
|
public Command command(long id,String key){return records.command(id,key);}
|
||||||
|
/** 命令与附加版本同事务保存,或作为独立上传意图持久化。 */
|
||||||
|
public void command(Command row){records.insertCommand(row);}
|
||||||
|
/** 上传结果只完成原待定意图。 */
|
||||||
|
public int finishUpload(Command row){return records.finishUpload(row);}
|
||||||
|
/** 同一附加版本只记录一次不可变历史。 */
|
||||||
|
public void history(History row){records.insertHistory(row);}
|
||||||
|
/** 历史总数只取原dispatch。 */
|
||||||
|
public int historyCount(long id){return records.historyCount(id);}
|
||||||
|
/** 历史采用稳定版本倒序。 */
|
||||||
|
public List<History> history(long id,int offset,int size){return records.history(id,offset,size);}
|
||||||
|
/** 下载历史附件时精确定位原版本。 */
|
||||||
|
public History revision(long id,long version){return records.revision(id,version);}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Command;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||||
|
|
||||||
|
/** 附加操作与文件准备共用二元唯一账本,不允许异账号复用键。 */
|
||||||
|
@Service
|
||||||
|
public class OwnedBusinessCommandService {
|
||||||
|
private final OwnedBusinessRepository repository;
|
||||||
|
private final OwnedBusinessSupport codec;
|
||||||
|
/** 文件意图独立短事务,不持业务行锁跨HTTP。 */
|
||||||
|
public OwnedBusinessCommandService(OwnedBusinessRepository repository,OwnedBusinessSupport codec){this.repository=repository;this.codec=codec;}
|
||||||
|
public record Reservation(Command command,boolean created) {}
|
||||||
|
/** 查找原命令时先核操作者,不披露别人的原载荷。 */
|
||||||
|
public Command existing(long dispatchId,String key,Actor actor) {
|
||||||
|
var row=repository.command(dispatchId,key);
|
||||||
|
if(row!=null && !Objects.equals(row.getOperatorId(),actor.operatorId()))
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"该请求键不属于当前操作账号");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
/** 原action、版本和完整规范请求必须一致,不能用当前版本重造旧意图。 */
|
||||||
|
public void same(Command row,String action,Long version,Object request) {
|
||||||
|
if(!Objects.equals(row.getAction(),action) || !Objects.equals(row.getExpectedVersion(),version)
|
||||||
|
|| !Objects.equals(row.getRequestHash(),codec.hash(java.util.Arrays.asList(row.getOperatorId(),action,version,request))))throw conflict("相同请求键的操作、版本或内容不同");
|
||||||
|
}
|
||||||
|
/** 完整命令实体只用于本页,不保存浏览器凭据。 */
|
||||||
|
public Command create(long id,Actor actor,String key,String action,Long version,Object request,String status) {
|
||||||
|
var now=codec.utc(Instant.now());var row=new Command();row.setId(IdWorker.getId());row.setDispatchId(id);
|
||||||
|
row.setOperatorId(actor.operatorId());row.setIdempotencyKey(key);row.setAction(action);row.setExpectedVersion(version);
|
||||||
|
row.setRequestHash(codec.hash(java.util.Arrays.asList(actor.operatorId(),action,version,request)));row.setRequestJson(codec.write(request));row.setStatus(status);row.setCreatedAt(now);row.setUpdatedAt(now);return row;
|
||||||
|
}
|
||||||
|
/** 首次上传先持久化意图,竞争者只能读取原状态,不再次调用File。 */
|
||||||
|
@Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
|
||||||
|
public Reservation reserveUpload(long id,Actor actor,String key,Object request) {
|
||||||
|
var previous=existing(id,key,actor);
|
||||||
|
if(previous!=null){same(previous,"UPLOAD_PHOTO",null,request);return new Reservation(previous,false);}
|
||||||
|
var row=create(id,actor,key,"UPLOAD_PHOTO",null,request,"PENDING");
|
||||||
|
try{repository.command(row);return new Reservation(row,true);}
|
||||||
|
catch(DuplicateKeyException race){var original=existing(id,key,actor);if(original==null)throw race;same(original,"UPLOAD_PHOTO",null,request);return new Reservation(original,false);}
|
||||||
|
}
|
||||||
|
/** 已知上传回执或未知结果只完成原意图,不创建新业务记录。 */
|
||||||
|
@Transactional(propagation=Propagation.REQUIRES_NEW)
|
||||||
|
public void finishUpload(Command command,String status,Object result,String errorCode) {
|
||||||
|
command.setStatus(status);command.setResultJson(result==null?null:codec.write(result));command.setErrorCode(errorCode);
|
||||||
|
command.setUpdatedAt(codec.utc(Instant.now()));
|
||||||
|
if(repository.finishUpload(command)!=1)throw conflict("上传命令结果已被其他操作确认,请核对原请求");
|
||||||
|
}
|
||||||
|
/** 写命令与附加更新、历史在同一外部事务提交。 */
|
||||||
|
public void success(long id,Actor actor,String key,String action,long version,Object request,Object result) {
|
||||||
|
var command=create(id,actor,key,action,version,request,"SUCCEEDED");command.setResultJson(codec.write(result));repository.command(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||||
|
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.*;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.Objects;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||||
|
|
||||||
|
/** 装车时车辆归属的唯一捕获实现,GET绝不补造历史。 */
|
||||||
|
@Service
|
||||||
|
public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapture {
|
||||||
|
private final OwnedBusinessRepository repository;
|
||||||
|
private final VehicleRepository vehicles;
|
||||||
|
/** 使用真实车辆提供者,不对车辆主档增加反向写锁。 */
|
||||||
|
public OwnedBusinessOwnershipService(OwnedBusinessRepository repository,VehicleRepository vehicles){this.repository=repository;this.vehicles=vehicles;}
|
||||||
|
/** 主流程原父事务已经稳定资源,捕获失败必须与装车一起回滚。 */
|
||||||
|
@Override @Transactional(propagation=Propagation.MANDATORY)
|
||||||
|
public void capture(long dispatchId,Long vehicleId,String resourceMode,String loadingCompletionHash,Instant loadingCompletedAt) {
|
||||||
|
if(dispatchId<=0 || loadingCompletionHash==null || loadingCompletionHash.isBlank() || loadingCompletionHash.length()>128 || loadingCompletedAt==null
|
||||||
|
|| !loadingCompletedAt.equals(loadingCompletedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)))throw bad("装车归属证据不完整");
|
||||||
|
if(!"MASTER".equals(resourceMode) && !"EXTERNAL".equals(resourceMode))throw bad("车辆资源模式不合法");
|
||||||
|
if("MASTER".equals(resourceMode)?vehicleId==null || vehicleId<=0:vehicleId!=null)throw bad("车辆资源身份不一致");
|
||||||
|
var existing=repository.ownership(dispatchId);
|
||||||
|
if(existing!=null){same(existing,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);return;}
|
||||||
|
var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode);
|
||||||
|
if("MASTER".equals(resourceMode)) {
|
||||||
|
var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在"));
|
||||||
|
if(vehicle.id()!=vehicleId.longValue())throw conflict("车辆归属提供者返回的身份不一致");
|
||||||
|
if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效");
|
||||||
|
if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确");
|
||||||
|
row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion());
|
||||||
|
} else {row.setVehicleOwnership("DISPATCHED");row.setVehicleRecordVersion(null);}
|
||||||
|
row.setLoadingCompletedAt(LocalDateTime.ofInstant(loadingCompletedAt,ZoneOffset.UTC));
|
||||||
|
row.setOwnershipCapturedAt(row.getLoadingCompletedAt());row.setLoadingCompletionHash(loadingCompletionHash);
|
||||||
|
try{repository.capture(row);}catch(DuplicateKeyException duplicate){
|
||||||
|
var raced=repository.ownership(dispatchId);if(raced==null)throw duplicate;
|
||||||
|
same(raced,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);
|
||||||
|
if(!Objects.equals(raced.getVehicleOwnership(),row.getVehicleOwnership()) || !Objects.equals(raced.getVehicleRecordVersion(),row.getVehicleRecordVersion()))throw conflict("装车车辆归属并发变化");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 只允许同一原装车事实重放,不能以新归属重写旧证据。 */
|
||||||
|
private void same(Ownership row,Long vehicleId,String mode,String hash,Instant at) {
|
||||||
|
if(!Objects.equals(row.getVehicleId(),vehicleId) || !Objects.equals(row.getResourceMode(),mode)
|
||||||
|
|| !Objects.equals(row.getLoadingCompletionHash(),hash) || !Objects.equals(row.getLoadingCompletedAt(),LocalDateTime.ofInstant(at,ZoneOffset.UTC)))
|
||||||
|
throw conflict("原装车车辆归属证据不同");
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.*;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
|
||||||
|
/** 只有绑定成功才保存照片,上传和绑定各自使用稳定键。 */
|
||||||
|
@Service
|
||||||
|
public class OwnedBusinessPhotoService {
|
||||||
|
private final OwnedBusinessQueryService queries;
|
||||||
|
private final OwnedBusinessRecordService records;
|
||||||
|
private final OwnedBusinessRepository repository;
|
||||||
|
private final OwnedBusinessCommandService commands;
|
||||||
|
private final OwnedBusinessProjection projection;
|
||||||
|
private final OwnedBusinessSupport codec;
|
||||||
|
private final OwnedBusinessFileGateway files;
|
||||||
|
/** HTTP远程请求不在附加行写事务内持锁等待。 */
|
||||||
|
public OwnedBusinessPhotoService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,
|
||||||
|
OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection,
|
||||||
|
OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
|
||||||
|
this.queries=queries;this.records=records;this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
|
||||||
|
}
|
||||||
|
public record UploadIdentity(PhotoSlot slot,String fileName,String contentType,long sizeBytes,String sha256) {}
|
||||||
|
public record Download(Photo photo,byte[] content) {}
|
||||||
|
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
|
||||||
|
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
|
||||||
|
return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */
|
||||||
|
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key);
|
||||||
|
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||||
|
if(slot==null || multipart==null)throw bad("请选择照片及分类");
|
||||||
|
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||||
|
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
|
||||||
|
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
|
||||||
|
HttpOwnedBusinessFileGateway.validate(payload);
|
||||||
|
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
|
||||||
|
var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
|
||||||
|
if(!reservation.created()) {
|
||||||
|
if(!"SUCCEEDED".equals(reservation.command().getStatus()))throw conflict("原上传结果尚未明确,请查询原请求,勿换键重传");
|
||||||
|
return codec.read(reservation.command().getResultJson(),Photo.class);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var result=photo(slot,files.upload(source.getId(),slot,actor.operatorId(),payload));
|
||||||
|
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
|
||||||
|
} catch(RuntimeException failure) {
|
||||||
|
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
|
||||||
|
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");}
|
||||||
|
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
|
||||||
|
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
|
||||||
|
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||||
|
return bind(actor,number,slot,request,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */
|
||||||
|
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot);
|
||||||
|
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
|
||||||
|
if(fileId<=0)throw bad("文件编号无效");
|
||||||
|
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||||
|
if(previous!=null) {
|
||||||
|
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
|
||||||
|
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
|
||||||
|
return codec.read(previous.getResultJson(),Detail.class);
|
||||||
|
}
|
||||||
|
var verified=photo(slot,files.metadata(source.getId(),slot,fileId));
|
||||||
|
return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope);
|
||||||
|
}
|
||||||
|
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||||
|
return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 五框历史授权仍由同车同版本的真实引用限定。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List<Photo> bound;
|
||||||
|
if(revision==null)bound=projection.photos(repository.record(source.getId()));
|
||||||
|
else {
|
||||||
|
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
|
||||||
|
if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||||
|
}
|
||||||
|
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing);
|
||||||
|
return new Download(photo,files.content(source.getId(),slot,fileId));
|
||||||
|
}
|
||||||
|
/** 恢复先校验原操作者,不披露他人载荷。 */
|
||||||
|
@Transactional(readOnly=true)
|
||||||
|
public Command command(Actor actor,String number,String key) {
|
||||||
|
return command(actor,number,key,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */
|
||||||
|
@Transactional(readOnly=true)
|
||||||
|
public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor);
|
||||||
|
if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||||
|
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
|
||||||
|
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
|
||||||
|
}
|
||||||
|
/** 上传回执不填写保存时间,避免假称已经关联成功。 */
|
||||||
|
private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence evidence) {
|
||||||
|
return new Photo(slot,slot.category(),String.valueOf(evidence.fileId()),evidence.fileName(),evidence.contentType(),
|
||||||
|
String.valueOf(evidence.sizeBytes()),evidence.uploadedAt(),null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
|
||||||
|
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import java.math.*;
|
||||||
|
import java.util.*;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
|
||||||
|
/** 组合原排车与附加信息;不复制、推进或重新归并订单。 */
|
||||||
|
@Component
|
||||||
|
public class OwnedBusinessProjection {
|
||||||
|
private final OwnedBusinessSupport codec;
|
||||||
|
/** 所有数值与日期转换沿统一显式规则。 */
|
||||||
|
public OwnedBusinessProjection(OwnedBusinessSupport codec){this.codec=codec;}
|
||||||
|
/** 未登记为null,不以默认油耗、金额或到达填充。 */
|
||||||
|
public Operations operations(Record record) {
|
||||||
|
if(record==null)return new Operations(null,null,null,List.of());
|
||||||
|
var money=record.getTotalAmount()==null?null:new Money(record.getCurrency(),record.getTotalAmount().setScale(2).toPlainString());
|
||||||
|
return new Operations(money,decimal(record.getActualDistanceMiles()),decimal(record.getFuelConsumptionLitresPer100Km()),
|
||||||
|
codec.list(record.getArrivalsJson(),Arrival.class));
|
||||||
|
}
|
||||||
|
/** 照片已绑定才出现在当前记录,无文件不生成占位图。 */
|
||||||
|
public List<Photo> photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);}
|
||||||
|
/** 使用原货物确定目的地key,保持与排车及到达登记一致。 */
|
||||||
|
public String destinationKey(CargoRow item) {
|
||||||
|
String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),
|
||||||
|
item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
|
||||||
|
if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key;
|
||||||
|
}
|
||||||
|
/** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */
|
||||||
|
public int palletCount(Source source) {
|
||||||
|
var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class);
|
||||||
|
if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失");
|
||||||
|
var ids=new HashSet<Long>();
|
||||||
|
for(var pallet:confirmed.pallets()) {
|
||||||
|
if(pallet==null || pallet.warehousePalletId()==null || pallet.warehousePalletId()<=0)throw OwnedBusinessSupport.conflict("实际装车托盘身份缺失");
|
||||||
|
ids.add(pallet.warehousePalletId());
|
||||||
|
}
|
||||||
|
return ids.size();
|
||||||
|
}
|
||||||
|
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
|
||||||
|
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
|
||||||
|
return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */
|
||||||
|
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
|
||||||
|
var resources=codec.read(source.getResourcesJson(),Resources.class);
|
||||||
|
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
|
||||||
|
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
|
||||||
|
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|
||||||
|
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
|
||||||
|
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
|
||||||
|
var destinations=new LinkedHashMap<String,Destination>();
|
||||||
|
for(var item:cargo) {
|
||||||
|
String key=destinationKey(item);
|
||||||
|
destinations.computeIfAbsent(key,k->new Destination(k,item.getDestinationWarehouseCode()==null || item.getDestinationWarehouseCode().isBlank()?item.getFbaWarehouseCode():item.getDestinationWarehouseCode(),item.getDestinationWarehouseName(),
|
||||||
|
item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment(
|
||||||
|
b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k)));
|
||||||
|
}
|
||||||
|
String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString();
|
||||||
|
var driver=resources.driver();
|
||||||
|
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
|
||||||
|
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
|
||||||
|
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
|
||||||
|
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
|
||||||
|
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
|
||||||
|
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
|
||||||
|
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
|
||||||
|
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||||
|
}
|
||||||
|
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
|
||||||
|
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
|
||||||
|
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
|
||||||
|
public String carrierName(Detail detail) {
|
||||||
|
return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null
|
||||||
|
?null:detail.dispatch().vehicleInfo().vehicle().carrierName();
|
||||||
|
}
|
||||||
|
/** 附加权限独立于原排车写动作。 */
|
||||||
|
private Gate gate(Actor actor,String permission) {
|
||||||
|
boolean allowed=actor!=null && actor.permissions()!=null && actor.permissions().contains(permission);
|
||||||
|
return new Gate(allowed,allowed?null:"没有该业务操作权限");
|
||||||
|
}
|
||||||
|
/** 数据库十进制保持显式单位,不以空值替换0。 */
|
||||||
|
private String decimal(BigDecimal value){return value==null?null:value.toPlainString();}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
|
||||||
|
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
|
||||||
|
@Service
|
||||||
|
public class OwnedBusinessQueryService {
|
||||||
|
private final OwnedBusinessRepository repository;
|
||||||
|
private final OwnedBusinessProjection projection;
|
||||||
|
private final OwnedBusinessSupport codec;
|
||||||
|
private final DispatchDetailReader dispatch;
|
||||||
|
/** 主单详情沿既有真实提供者,列表使用本域批量投影。 */
|
||||||
|
public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection,
|
||||||
|
OwnedBusinessSupport codec,DispatchDetailReader dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;}
|
||||||
|
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public ListResult list(Actor actor,Query query) {
|
||||||
|
return listScoped(actor,query,OwnedBusinessScope.OWNED).result();
|
||||||
|
}
|
||||||
|
/** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */
|
||||||
|
public record ScopedListResult(ListResult result,Map<String,String> carrierNames) {}
|
||||||
|
/** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失");
|
||||||
|
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
|
||||||
|
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
|
||||||
|
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
|
||||||
|
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||||
|
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope);
|
||||||
|
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
|
||||||
|
for(var c:counts) {
|
||||||
|
int n=Math.toIntExact(c.getQuantity());
|
||||||
|
switch(c.getStage()){case "AFTER_LOADING"->after=n;case "DELIVERING"->delivering=n;case "DELIVERED"->delivered=n;default->throw conflict("阶段统计事实不合法");}
|
||||||
|
}
|
||||||
|
int total=Math.addExact(Math.addExact(after,delivering),delivered);
|
||||||
|
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
|
||||||
|
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
|
||||||
|
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
|
||||||
|
var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()),
|
||||||
|
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
|
||||||
|
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList();
|
||||||
|
var carriers=new LinkedHashMap<String,String>();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source));
|
||||||
|
return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers));
|
||||||
|
}
|
||||||
|
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
|
||||||
|
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
|
||||||
|
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
|
||||||
|
public Detail detailForWrite(Actor actor,Source source) {
|
||||||
|
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
|
||||||
|
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
|
||||||
|
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||||
|
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
|
||||||
|
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
|
||||||
|
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
|
||||||
|
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
|
||||||
|
projection.photos(repository.record(source.getId())));
|
||||||
|
}
|
||||||
|
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
|
||||||
|
public Source requireOwned(String number) {
|
||||||
|
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||||
|
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
|
||||||
|
}
|
||||||
|
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
|
||||||
|
public Source requireScoped(String number,OwnedBusinessScope scope) {
|
||||||
|
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
|
||||||
|
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||||
|
var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source;
|
||||||
|
}
|
||||||
|
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
|
||||||
|
private void eligible(Source source,OwnedBusinessScope scope) {
|
||||||
|
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||||
|
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
|
||||||
|
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|
||||||
|
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
|
||||||
|
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
|
||||||
|
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
|
||||||
|
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
|
||||||
|
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
|
||||||
|
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
|
||||||
|
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||||
|
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
|
||||||
|
}
|
||||||
|
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
|
||||||
|
return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */
|
||||||
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
|
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize);
|
||||||
|
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||||
|
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
|
||||||
|
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
|
||||||
|
return new History(h.getEventSource(),h.getRecordVersion()==null?null:h.getRecordVersion().toString(),
|
||||||
|
h.getDispatchRecordVersion()==null?null:h.getDispatchRecordVersion().toString(),h.getAction(),snapshot==null?null:snapshot.operatorName(),codec.iso(h.getOccurredAt()),h.getReason(),
|
||||||
|
snapshot==null?null:snapshot.operations(),snapshot==null?List.of():snapshot.photos());
|
||||||
|
}).toList(),page,size,repository.historyCount(source.getId()));
|
||||||
|
}
|
||||||
|
/** 页码初始1,缺失与非法值不能混同。 */
|
||||||
|
private int page(Integer value){if(value==null)return 1;if(value<1)throw bad("页码须大于0");return value;}
|
||||||
|
/** 列表和历史均限制单页读取量。 */
|
||||||
|
private int size(Integer value){if(value==null)return 20;if(value<1 || value>200)throw bad("每页条数须为1至200");return value;}
|
||||||
|
/** 空白可选条件归一化,无限长输入明确拒绝。 */
|
||||||
|
private String optional(String value,int max){if(value==null || value.isBlank())return null;if(value.length()>max)throw bad("筛选条件过长");return value.trim();}
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
|
||||||
|
/** 运营信息和分类照片只写本页附加版本,绝不改主单版本或发布费用。 */
|
||||||
|
@Service
|
||||||
|
public class OwnedBusinessRecordService {
|
||||||
|
private final OwnedBusinessRepository repository;
|
||||||
|
private final OwnedBusinessQueryService queries;
|
||||||
|
private final OwnedBusinessProjection projection;
|
||||||
|
private final OwnedBusinessCommandService commands;
|
||||||
|
private final OwnedBusinessSupport codec;
|
||||||
|
/** 业务规则经Repository读写,命令与历史一起提交。 */
|
||||||
|
public OwnedBusinessRecordService(OwnedBusinessRepository repository,OwnedBusinessQueryService queries,
|
||||||
|
OwnedBusinessProjection projection,OwnedBusinessCommandService commands,OwnedBusinessSupport codec) {
|
||||||
|
this.repository=repository;this.queries=queries;this.projection=projection;this.commands=commands;this.codec=codec;
|
||||||
|
}
|
||||||
|
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
|
||||||
|
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||||
|
public Detail save(Actor actor,String number,SaveOperations request) {
|
||||||
|
return save(actor,number,request,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */
|
||||||
|
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||||
|
public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||||
|
if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null))
|
||||||
|
throw bad("外配业务不支持里程或油耗登记");
|
||||||
|
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
|
||||||
|
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||||
|
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
|
||||||
|
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
|
||||||
|
var ops=normalize(source,request.operations());var row=lock(source,actor);
|
||||||
|
previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||||
|
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
|
||||||
|
requireVersion(row,version);
|
||||||
|
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
|
||||||
|
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
|
||||||
|
row.setArrivalsJson(codec.write(ops.arrivals()));
|
||||||
|
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope);
|
||||||
|
}
|
||||||
|
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
|
||||||
|
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||||
|
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
|
||||||
|
return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */
|
||||||
|
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||||
|
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) {
|
||||||
|
codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot);
|
||||||
|
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||||
|
Object identity=new PhotoBinding(slot,request);
|
||||||
|
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||||
|
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
|
||||||
|
if(slot!=verified.slot() || !Objects.equals(slot.category(),verified.category()) || !Objects.equals(request.fileId(),verified.fileId()))throw bad("照片不属于本框");
|
||||||
|
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||||
|
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
|
||||||
|
requireVersion(row,version);
|
||||||
|
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
|
||||||
|
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
|
||||||
|
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||||
|
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
|
||||||
|
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
||||||
|
}
|
||||||
|
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
||||||
|
private String photoReason(PhotoSlot slot) {
|
||||||
|
return switch(slot) {
|
||||||
|
case LOADING_SCENE -> "保存装车现场照片";
|
||||||
|
case LOADING_LIST -> "保存装车清单照片";
|
||||||
|
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
|
||||||
|
case DRIVER_LICENSE -> "保存驾驶证附件照片";
|
||||||
|
case RECEIPT -> "保存签收凭证照片";
|
||||||
|
case FUEL_START -> "保存起始油耗表盘照片";
|
||||||
|
case FUEL_END -> "保存结束油耗表盘照片";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** 照片框也参与摘要,不能换URL框位复用原键。 */
|
||||||
|
public record PhotoBinding(PhotoSlot slot,BindPhoto request) {}
|
||||||
|
/** 先校验所有目的地及数值,再进入附加行写事务。 */
|
||||||
|
private Operations normalize(Source source,Operations ops) {
|
||||||
|
Money money=null;
|
||||||
|
if(ops.money()!=null) {
|
||||||
|
if(!Set.of("CNY","USD").contains(ops.money().currency()==null?"":ops.money().currency()))throw bad("币种须为CNY或USD");
|
||||||
|
String amount=codec.decimal(ops.money().amount(),16,2);if(amount==null)throw bad("金额与币种必须同时登记");
|
||||||
|
money=new Money(ops.money().currency(),amount);
|
||||||
|
}
|
||||||
|
if(ops.arrivals()==null)throw bad("目的地到达登记不完整");
|
||||||
|
var destinations=new HashSet<String>();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item));
|
||||||
|
var seen=new HashSet<String>();var arrivals=new ArrayList<Arrival>();
|
||||||
|
for(var arrival:ops.arrivals()) {
|
||||||
|
if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车");
|
||||||
|
var at=codec.instant(arrival.actualArrivedAt());arrivals.add(new Arrival(arrival.destinationKey(),at==null?null:at.toString()));
|
||||||
|
}
|
||||||
|
arrivals.sort(Comparator.comparing(Arrival::destinationKey));
|
||||||
|
return new Operations(money,codec.decimal(ops.actualDistanceMiles(),9,3),
|
||||||
|
codec.decimal(ops.fuelConsumptionLitresPer100Km(),9,3),List.copyOf(arrivals));
|
||||||
|
}
|
||||||
|
/** 首次写建立0版本附加行,仅在成功事务中最终提交。 */
|
||||||
|
private Record lock(Source source,Actor actor) {
|
||||||
|
var seed=new Record();seed.setId(IdWorker.getId());seed.setDispatchId(source.getId());seed.setCreatedBy(actor.operatorId());
|
||||||
|
seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
|
||||||
|
var row=repository.lock(seed);if(row==null)throw conflict("附加记录初始化失败");return row;
|
||||||
|
}
|
||||||
|
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
|
||||||
|
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
|
||||||
|
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
|
||||||
|
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) {
|
||||||
|
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
|
||||||
|
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
|
||||||
|
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
|
||||||
|
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
|
||||||
|
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
|
||||||
|
var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope);
|
||||||
|
commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||||
|
}
|
||||||
|
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
|
||||||
|
private Detail result(OwnedBusinessRows.Command command) {
|
||||||
|
if(!"SUCCEEDED".equals(command.getStatus()))throw conflict("原命令结果尚未明确,请按原请求恢复");
|
||||||
|
return codec.read(command.getResultJson(),Detail.class);
|
||||||
|
}
|
||||||
|
/** null是未登记,不以0代替。 */
|
||||||
|
private BigDecimal decimal(String value){return value==null?null:new BigDecimal(value);}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.JavaType;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** 只处理本页格式、身份和JSON,不读写主流程。 */
|
||||||
|
@Component
|
||||||
|
public class OwnedBusinessSupport {
|
||||||
|
private final ObjectMapper json;
|
||||||
|
/** 复用服务统一Jackson,不建立第二套日期或版本协议。 */
|
||||||
|
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
|
||||||
|
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
|
||||||
|
public Actor actor(AuthenticatedUser user) {
|
||||||
|
return actor(user,OwnedBusinessScope.OWNED);
|
||||||
|
}
|
||||||
|
/** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */
|
||||||
|
public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) {
|
||||||
|
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||||
|
String operator=user.userId()==null?user.username():user.userId().toString();
|
||||||
|
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
|
||||||
|
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
|
||||||
|
return new Actor(operator,name,user.permissions()==null?Set.of():Set.copyOf(user.permissions()));
|
||||||
|
}
|
||||||
|
/** 写权限独立校验,直接Service调用也不能绕过。 */
|
||||||
|
public void require(Actor actor,String permission) {
|
||||||
|
if(actor==null || actor.operatorId()==null || actor.permissions()==null || !actor.permissions().contains(permission))
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该业务操作权限");
|
||||||
|
}
|
||||||
|
/** JSON只从本页记录和已确认排车事实读取,损坏不静默恢复空值。 */
|
||||||
|
public <T>T read(String value,Class<T> type) {
|
||||||
|
if(value==null || value.isBlank())throw conflict("缺少可靠业务事实");
|
||||||
|
try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");}
|
||||||
|
}
|
||||||
|
/** 集合记录与声明的元素类型共同校验。 */
|
||||||
|
public <T>List<T> list(String value,Class<T> type) {
|
||||||
|
JavaType listType=json.getTypeFactory().constructCollectionType(List.class,type);
|
||||||
|
try{return json.readValue(value,listType);}catch(Exception error){throw conflict("业务集合事实读取失败");}
|
||||||
|
}
|
||||||
|
/** 原请求和原结果完整持久化,序列化失败必须中止命令。 */
|
||||||
|
public String write(Object value) {
|
||||||
|
try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("业务记录序列化失败",error);}
|
||||||
|
}
|
||||||
|
/** 请求摘要不包含版本漂移后的当前数据。 */
|
||||||
|
public String hash(Object value){return digest(write(value).getBytes(StandardCharsets.UTF_8));}
|
||||||
|
/** 文件内容摘要用于识别相同上传原请求,不保存图片进数据库。 */
|
||||||
|
public String digest(byte[] bytes) {
|
||||||
|
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
|
||||||
|
catch(Exception error){throw new IllegalStateException("无法计算业务摘要",error);}
|
||||||
|
}
|
||||||
|
/** 版本0合法,但缺失、负数和超长版本不得降级为0。 */
|
||||||
|
public long version(String value) {
|
||||||
|
try{if(value==null || !value.matches("0|[1-9][0-9]*"))throw new IllegalArgumentException();return Long.parseLong(value);}
|
||||||
|
catch(Exception error){throw bad("请提交有效的原记录版本");}
|
||||||
|
}
|
||||||
|
/** 请求键在页面恢复期间保持稳定,不能用空键创建命令。 */
|
||||||
|
public void key(String key){if(key==null || key.length()<8 || key.length()>128)throw bad("请求键长度须为8至128");}
|
||||||
|
/** 只接受带时区的真实时间,统一存UTC。 */
|
||||||
|
public Instant instant(String value) {
|
||||||
|
try{return value==null?null:OffsetDateTime.parse(value).toInstant();}
|
||||||
|
catch(Exception error){throw bad("时间须包含明确时区");}
|
||||||
|
}
|
||||||
|
/** 关系库时间只表示UTC,不取运行机默认时区。 */
|
||||||
|
public LocalDateTime utc(Instant value){return value==null?null:LocalDateTime.ofInstant(value,ZoneOffset.UTC);}
|
||||||
|
/** 展示原真实时间,不根据签收或预计时间填充。 */
|
||||||
|
public String iso(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
|
||||||
|
/** 规范十进制输入,空值与明确零分别保存。 */
|
||||||
|
public String decimal(String value,int integers,int scale) {
|
||||||
|
if(value==null)return null;
|
||||||
|
if(!value.matches("(0|[1-9][0-9]{0,"+(integers-1)+"})(\\.[0-9]{1,"+scale+"})?"))throw bad("数量或金额须为非负规范十进制");
|
||||||
|
return new BigDecimal(value).setScale(scale).toPlainString();
|
||||||
|
}
|
||||||
|
/** 用户输入错误保持项目业务错误约定。 */
|
||||||
|
public static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
|
||||||
|
/** 业务并发或来源事实不符不能返回模拟成功。 */
|
||||||
|
public static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
|
||||||
|
/** 不存在只使用业务提示,不泄露内部主键。 */
|
||||||
|
public static BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前自有业务");}
|
||||||
|
}
|
||||||
+10
-1
@@ -36,7 +36,16 @@ public final class FleetCapacityOfferModels {
|
|||||||
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
|
@NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode,
|
||||||
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
|
Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount,
|
||||||
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
|
@NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef,
|
||||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion,
|
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,
|
@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||||
@Size(max=1000) String reason) { }
|
@Size(max=1000) String reason) { }
|
||||||
|
|||||||
+11
-1
@@ -25,6 +25,7 @@ import java.util.*;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class FleetCapacityOfferService {
|
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";
|
public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage";
|
||||||
private final FleetCapacityOfferMapper offers;
|
private final FleetCapacityOfferMapper offers;
|
||||||
private final FleetCapacityOfferCommandMapper commands;
|
private final FleetCapacityOfferCommandMapper commands;
|
||||||
@@ -50,6 +51,11 @@ public class FleetCapacityOfferService {
|
|||||||
this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService;
|
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;
|
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)
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
|
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
|
||||||
readPermission();
|
readPermission();
|
||||||
@@ -171,17 +177,20 @@ public class FleetCapacityOfferService {
|
|||||||
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
|
touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);
|
||||||
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
|
saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result;
|
||||||
}
|
}
|
||||||
|
/** 同一事务登记意向及原单来源,确认前不占位。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
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);
|
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);
|
var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
|
||||||
if(replay!=null)return replayed(replay);
|
if(replay!=null)return replayed(replay);
|
||||||
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
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()));
|
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("意向创建记录不完整,请保留原请求联系调度核对");
|
if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对");
|
||||||
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
|
var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request);
|
||||||
bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
|
bookings.updateById(booking);demands.attach(booking.getId(),source);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result;
|
||||||
}
|
}
|
||||||
|
/** 编辑登记资料时保持客户及原单来源不可变。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){
|
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());
|
writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());
|
||||||
@@ -189,6 +198,7 @@ public class FleetCapacityOfferService {
|
|||||||
var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay);
|
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);
|
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request);
|
||||||
if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记");
|
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("缺少意向版本");
|
if(request.recordVersion()==null)throw bad("缺少意向版本");
|
||||||
bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef()));
|
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);
|
booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking);
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.trip;
|
||||||
|
|
||||||
|
/** 日历专用只读查询投影,真实排车号不属于车次持久化字段。 */
|
||||||
|
public class FleetTripCalendarRow extends FleetTripEntity {
|
||||||
|
private String dispatchNo;
|
||||||
|
|
||||||
|
/** 返回关联整车主单的业务号。 */
|
||||||
|
public String getDispatchNo() { return dispatchNo; }
|
||||||
|
|
||||||
|
/** 仅供日历SQL结果映射,不写入车次表。 */
|
||||||
|
public void setDispatchNo(String dispatchNo) { this.dispatchNo = dispatchNo; }
|
||||||
|
}
|
||||||
+5
-4
@@ -11,10 +11,10 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import java.time.*;
|
import java.time.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/** Read-only transport plans. A trip is never evidence of a receiving appointment confirmation. */
|
/** 排车日历沿真实整车主单读取运输时间,不代表已取得目的仓收货确认。 */
|
||||||
@Service
|
@Service
|
||||||
public class FleetTripCalendarService {
|
public class FleetTripCalendarService {
|
||||||
public record Entry(String sourceType, String tripNo, String status, String originWarehouseId,
|
public record Entry(String sourceType, String dispatchNo, String status, String originWarehouseId,
|
||||||
String originWarehouseCode, String originWarehouseName, String originCountryCode,
|
String originWarehouseCode, String originWarehouseName, String originCountryCode,
|
||||||
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
|
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
|
||||||
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
|
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
|
||||||
@@ -31,12 +31,13 @@ public class FleetTripCalendarService {
|
|||||||
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
|
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
|
||||||
@Transactional(readOnly=true)
|
@Transactional(readOnly=true)
|
||||||
public Result calendar(Instant from, Instant to) {
|
public Result calendar(Instant from, Instant to) {
|
||||||
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
|
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
|
||||||
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
|
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
|
||||||
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次过多,请缩小查询日期范围");
|
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
|
||||||
if (rows.isEmpty()) return new Result(List.of(),List.of());
|
if (rows.isEmpty()) return new Result(List.of(),List.of());
|
||||||
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
|
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
|
||||||
var metadata=metadata(ids);
|
var metadata=metadata(ids);
|
||||||
@@ -60,7 +61,7 @@ public class FleetTripCalendarService {
|
|||||||
throw new IllegalArgumentException("incomplete route");
|
throw new IllegalArgumentException("incomplete route");
|
||||||
stops=List.copyOf(stops);
|
stops=List.copyOf(stops);
|
||||||
} catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); }
|
} catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); }
|
||||||
var entry=new Entry("TRIP_PLAN",row.getTripNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
|
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
|
||||||
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
|
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
|
||||||
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
|
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
|
||||||
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
|
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
|
||||||
|
|||||||
+1
-1
@@ -276,7 +276,7 @@ public class FleetTripExecutionService {
|
|||||||
}
|
}
|
||||||
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
|
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
|
||||||
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
|
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
|
||||||
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作");
|
if (java.util.Set.of("PICKUP","LOCAL_TRUCK").contains(java.util.Objects.toString(trip.getTripPurpose(),""))) throw conflict("运输排车请使用对应运输任务操作");
|
||||||
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
|
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
|
||||||
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
|
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ import org.apache.ibatis.annotations.Select;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
|
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
|
||||||
/** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */
|
/** 日历只读真实整车主单的出库派送,一单一条;独立车次及取消主单不进入排车日历。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,
|
SELECT d.dispatch_no,t.trip_no,t.status,t.warehouse_id,t.warehouse_code,t.warehouse_name,t.planned_departure_at,
|
||||||
expected_driver_end_at,departed_at,completed_at,stops_json
|
t.expected_driver_end_at,t.departed_at,t.completed_at,t.stops_json
|
||||||
FROM ll_fleet_trip
|
FROM ll_dispatch_order d JOIN ll_fleet_trip t ON t.id=d.trip_id
|
||||||
WHERE trip_purpose='DELIVERY' AND (
|
WHERE d.status='ACTIVE' AND d.dispatch_no IS NOT NULL AND TRIM(d.dispatch_no)<>''
|
||||||
(departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
|
AND t.trip_purpose='DELIVERY' AND (
|
||||||
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to}))
|
(t.departed_at IS NOT NULL AND t.departed_at>=#{from} AND t.departed_at<#{to})
|
||||||
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001
|
OR (t.departed_at IS NULL AND t.planned_departure_at>=#{from} AND t.planned_departure_at<#{to}))
|
||||||
|
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
|
||||||
""")
|
""")
|
||||||
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
|
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
|
||||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
|
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
|
||||||
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
|
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
|
||||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
|
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import java.util.*;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class FleetTripService {
|
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.fleet.trip.work.AppointmentWorkTripChanges workChanges;
|
||||||
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
|
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
|
||||||
private final FleetTripRepository repository;
|
private final FleetTripRepository repository;
|
||||||
@@ -101,6 +102,12 @@ public class FleetTripService {
|
|||||||
return createPlan(operator, request, "PICKUP");
|
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)
|
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||||
public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) {
|
public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) {
|
||||||
@@ -376,7 +383,7 @@ public class FleetTripService {
|
|||||||
|
|
||||||
/** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */
|
/** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */
|
||||||
private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) {
|
private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) {
|
||||||
String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request));
|
String hash = hash(("DELIVERY".equals(purpose) ? "" : purpose+":") + write(request));
|
||||||
var existing = repository.findCreated(operator, request.idempotencyKey());
|
var existing = repository.findCreated(operator, request.idempotencyKey());
|
||||||
if (existing != null) return replay(existing, hash);
|
if (existing != null) return replay(existing, hash);
|
||||||
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
|
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
|
||||||
@@ -447,7 +454,7 @@ public class FleetTripService {
|
|||||||
bookingReservations.requireRoute(trip, request.stops());
|
bookingReservations.requireRoute(trip, request.stops());
|
||||||
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
|
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
|
||||||
boolean unchangedRoute = response(trip).stops().equals(request.stops());
|
boolean unchangedRoute = response(trip).stops().equals(request.stops());
|
||||||
if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线");
|
if (Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")) && !unchangedRoute) throw conflict("运输路线由原单确定,不能修改来源地址");
|
||||||
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
|
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
|
||||||
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
|
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
|
||||||
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
|
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
|
||||||
@@ -643,6 +650,7 @@ public class FleetTripService {
|
|||||||
}
|
}
|
||||||
/** 为当前版本保存操作人、原因和完整车次历史。 */
|
/** 为当前版本保存操作人、原因和完整车次历史。 */
|
||||||
private void record(FleetTripEntity trip, String action, String reason, String operator) {
|
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);
|
workChanges.changed(trip);
|
||||||
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
|
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());
|
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
|
||||||
|
|||||||
+3
@@ -29,6 +29,9 @@ public class PlatformFleetCapacityOfferController {
|
|||||||
public ApiResponse<FleetCapacityOfferModels.OfferResult> resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));}
|
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')")
|
@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));}
|
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')")
|
@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));}
|
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')")
|
@GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||||
|
|||||||
+3
-2
@@ -18,13 +18,14 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
@RequestMapping("/api/platform/v1/last-leg/fleet/trips")
|
@RequestMapping("/api/platform/v1/last-leg/fleet/trips")
|
||||||
public class PlatformFleetTripController {
|
public class PlatformFleetTripController {
|
||||||
@jakarta.annotation.Resource private FleetTripCalendarService calendar;
|
@jakarta.annotation.Resource private FleetTripCalendarService calendar;
|
||||||
|
/** 预约排车日历沿用真实排车列表读取权限,不要求已退出菜单的车次管理权限。 */
|
||||||
@GetMapping("/calendar")
|
@GetMapping("/calendar")
|
||||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
|
||||||
public ApiResponse<FleetTripCalendarService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<FleetTripCalendarService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestParam java.time.Instant from,@RequestParam java.time.Instant to) {
|
@RequestParam java.time.Instant from,@RequestParam java.time.Instant to) {
|
||||||
operator(user);
|
operator(user);
|
||||||
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取排车日历");
|
||||||
return ApiResponse.success(calendar.calendar(from,to));
|
return ApiResponse.success(calendar.calendar(from,to));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.trip;
|
||||||
|
|
||||||
|
|
||||||
|
/** 同步事务内的运输事实;任一投影失败时原执行一起回滚。 */
|
||||||
|
public record TransportTripChanged(FleetTripEntity trip,String action,String reason,String operator) { }
|
||||||
+4
-4
@@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper {
|
|||||||
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
|
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
|
||||||
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
|
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
|
||||||
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
|
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
|
||||||
/** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */
|
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
|
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
|
||||||
EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id))
|
EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id))
|
||||||
AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked,
|
AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked,
|
||||||
l.order_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
|
l.order_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
|
||||||
@@ -31,7 +31,7 @@ public interface AppointmentWorkDispatchMapper {
|
|||||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||||
WHERE <foreach collection='items' item='i' open='(' separator=' OR ' close=')'>(p.id=#{i.planPalletId} AND p.customer_id=#{i.customerId})</foreach>
|
WHERE <foreach collection='items' item='i' open='(' separator=' OR ' close=')'>(p.id=#{i.planPalletId} AND p.customer_id=#{i.customerId})</foreach>
|
||||||
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,l.order_id,l.destination_type,l.fba_warehouse_code
|
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,l.order_id,l.destination_type,l.fba_warehouse_code
|
||||||
ORDER BY p.id,l.order_id,l.destination_type,l.fba_warehouse_code LIMIT 20001
|
ORDER BY p.id,l.order_id,l.destination_type,l.fba_warehouse_code LIMIT 20001
|
||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
@@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper {
|
|||||||
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
|
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
|
||||||
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
|
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
|
||||||
@Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List<DispatchPhysical> dispatchPhysicalById(long id);
|
@Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List<DispatchPhysical> dispatchPhysicalById(long id);
|
||||||
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
|
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
|
||||||
List<Candidate> candidates(long id);
|
List<Candidate> candidates(long id);
|
||||||
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
|
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
|
||||||
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
|
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
|
||||||
|
|||||||
+4
-5
@@ -51,7 +51,7 @@ public class LocalLooseboardService {
|
|||||||
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
|
||||||
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
|
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
||||||
LocalLooseboardRequests.Create request) {
|
LocalLooseboardRequests.Create request) {
|
||||||
@@ -75,8 +75,7 @@ public class LocalLooseboardService {
|
|||||||
order.setVersion(0L);
|
order.setVersion(0L);
|
||||||
order.setCreatedAt(now);
|
order.setCreatedAt(now);
|
||||||
order.setUpdatedAt(now);
|
order.setUpdatedAt(now);
|
||||||
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
|
// 下单只保存池内需求,实际收货时再创建唯一实物尾程单。
|
||||||
order.setLastlegOrderId(lastlegOrder.getId());
|
|
||||||
try {
|
try {
|
||||||
repository.insertOrder(order);
|
repository.insertOrder(order);
|
||||||
} catch (DuplicateKeyException exception) {
|
} catch (DuplicateKeyException exception) {
|
||||||
@@ -86,8 +85,7 @@ public class LocalLooseboardService {
|
|||||||
}
|
}
|
||||||
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
|
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
|
||||||
key, null, request.remark(), now);
|
key, null, request.remark(), now);
|
||||||
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
|
|
||||||
"Local looseboard order submitted", now);
|
|
||||||
return detail(customerId, order.getOrderNo());
|
return detail(customerId, order.getOrderNo());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +156,7 @@ public class LocalLooseboardService {
|
|||||||
}
|
}
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||||
String next = norm(request.status());
|
String next = norm(request.status());
|
||||||
|
if (order.getLastlegOrderId() == null && !CANCELLED.equals(next)) throw conflict("请从散板需求池匹配车次,沿实际收货、排车和签收推进");
|
||||||
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
|
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
|
||||||
String from = order.getStatus();
|
String from = order.getStatus();
|
||||||
if (next.equals(from)) return response(order);
|
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) position = mapper.position(item.getSourceInventoryPositionId());
|
||||||
if (position == null) continue;
|
if (position == null) continue;
|
||||||
positions.put(position.getId(), position);
|
positions.put(position.getId(), position);
|
||||||
|
// 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
|
||||||
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
|
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
|
||||||
|
&& position.getPalletId() > 0
|
||||||
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
|
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
|
|||||||
long notified = number(item.getNotifiedPackageCount());
|
long notified = number(item.getNotifiedPackageCount());
|
||||||
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|
||||||
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
|
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
|
||||||
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
|
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
|
||||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
|
|| position.getPalletId() <= 0) {
|
||||||
|
reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
|
||||||
bound = false;
|
bound = false;
|
||||||
}
|
}
|
||||||
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
|
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
|
||||||
|
|||||||
+3
@@ -8,6 +8,9 @@ import org.apache.ibatis.annotations.Select;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface UsTruckOrderMapper extends BaseMapper<UsTruckOrder> {
|
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")
|
@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);
|
UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
|
||||||
|
|
||||||
|
|||||||
+2
@@ -44,6 +44,8 @@ public class UsTruckOrderRepository {
|
|||||||
public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); }
|
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 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); }
|
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()); 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()); if(repository.hasActiveDispatch(customerId,orderNo))throw conflict("该单已接入排车,请在排车列表执行运输操作"); 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()); }
|
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
||||||
/** 读取订单资料与实际托盘和操作记录。 */
|
/** 读取订单资料与实际托盘和操作记录。 */
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
|
|||||||
PRIMARY KEY(business_date)
|
PRIMARY KEY(business_date)
|
||||||
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
|
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||||
|
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||||
|
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||||
|
PRIMARY KEY(business_date)
|
||||||
|
) ENGINE=InnoDB COMMENT='排车单号每日独立原子流水';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_order (
|
CREATE TABLE IF NOT EXISTS ll_order (
|
||||||
id BIGINT NOT NULL COMMENT '雪花 ID',
|
id BIGINT NOT NULL COMMENT '雪花 ID',
|
||||||
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
||||||
@@ -1202,6 +1208,7 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
||||||
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
||||||
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
||||||
|
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
|
||||||
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
||||||
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
||||||
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
||||||
@@ -1216,6 +1223,7 @@ CREATE TABLE ll_fleet_external_booking (
|
|||||||
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
|
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 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),
|
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),
|
PRIMARY KEY(id),
|
||||||
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
||||||
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
||||||
@@ -1892,6 +1900,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
|
|||||||
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||||
dispatch_no VARCHAR(64) 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',
|
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||||
@@ -4553,3 +4562,108 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
|
|||||||
KEY idx_pickup_history_source(customer_id,transfer_no,id),
|
KEY idx_pickup_history_source(customer_id,transfer_no,id),
|
||||||
KEY idx_pickup_history_trip(trip_no,id)
|
KEY idx_pickup_history_trip(trip_no,id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||||
|
|
||||||
|
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
|
||||||
|
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
||||||
|
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
|
||||||
|
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
|
||||||
|
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
|
||||||
|
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
|
||||||
|
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
|
||||||
|
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
|
||||||
|
PRIMARY KEY (dispatch_id),
|
||||||
|
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
|
||||||
|
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
|
||||||
|
CONSTRAINT ck_owned_business_resource_mode CHECK (
|
||||||
|
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
|
||||||
|
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
|
||||||
|
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
|
||||||
|
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
|
||||||
|
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
|
||||||
|
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
|
||||||
|
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
|
||||||
|
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
|
||||||
|
photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照',
|
||||||
|
created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识',
|
||||||
|
updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id),
|
||||||
|
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
|
||||||
|
CONSTRAINT ck_owned_business_money CHECK (
|
||||||
|
(currency IS NULL AND total_amount IS NULL)
|
||||||
|
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
|
||||||
|
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
|
||||||
|
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
|
||||||
|
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
|
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
|
||||||
|
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
|
||||||
|
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
|
||||||
|
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
|
||||||
|
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
|
||||||
|
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
|
||||||
|
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
|
||||||
|
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
|
||||||
|
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
|
||||||
|
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||||
|
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
|
||||||
|
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||||
|
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
|
||||||
|
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
|
||||||
|
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识',
|
||||||
|
reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明',
|
||||||
|
snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照',
|
||||||
|
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version),
|
||||||
|
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||||
|
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
|
||||||
|
|
||||||
|
|
||||||
|
-- 运输原单接入现有排车,不产生仓内库存事实。
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||||
|
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||||
|
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||||
|
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
||||||
|
KEY idx_transport_order(customer_id,order_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
|
||||||
|
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
|
||||||
|
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||||
|
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||||
|
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||||
|
PRIMARY KEY(dispatch_no,request_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user