Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb50d5f60 | ||
|
|
6877c372bd | ||
|
|
3ceafa9ca7 | ||
|
|
a43c4d3321 | ||
|
|
2ec4627db6 | ||
|
|
c324de9c32 | ||
|
|
9f3755f8e2 | ||
|
|
8e9cbf6aa2 | ||
|
|
6771eba2d4 | ||
|
|
0dd9c8db07 | ||
|
|
0ff1eefff6 | ||
|
|
da6b4685d7 | ||
|
|
e0c0316487 | ||
|
|
bf2508b6d6 | ||
|
|
a870b920fe | ||
|
|
19745e46c1 |
@@ -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)
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 |
|
||||
| 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL |
|
||||
|
||||
已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。文件显式指定 `USE baiyue_lastleg`,不访问其他服务数据库。
|
||||
已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。由单 schema 执行器选定 `baiyue_lastleg`,文件内不切库,不访问其他服务数据库。2026-10-20 按尚未执行候选的边界移除内部切库语句,其余结构内容不变;本次未执行此候选。
|
||||
|
||||
## 新表与字段
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 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 已执行,也不能向已有测试库重放空库脚本。
|
||||
|
||||
|
||||
@@ -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='排车单号每日独立原子流水';
|
||||
@@ -1,15 +0,0 @@
|
||||
-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。
|
||||
USE 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;
|
||||
+1
-3
@@ -2,7 +2,7 @@
|
||||
-- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。
|
||||
-- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_lastleg;
|
||||
-- 由单 schema 执行器选择 baiyue_lastleg,不在迁移内部切库。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
|
||||
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
||||
@@ -11,7 +11,6 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
||||
|
||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||
@@ -42,7 +41,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||
|
||||
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
|
||||
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Region: OVERSEAS
|
||||
-- Schema: baiyue_lastleg
|
||||
-- Purpose: 排车物理托盘串行点、物理占用及逻辑占用关联结构补齐。
|
||||
-- Preconditions: MySQL 8;已选中目标schema;ll_dispatch_pallet_lock已有业务基线存在。
|
||||
-- Postconditions: 两张实托表和physical_lock_id列/idx_dispatch_logical_physical索引存在。
|
||||
-- 只增加缺失结构,不删除/回填/重算历史数据。重复执行已存在对象时跳过。
|
||||
-- 与当前lastleg.sql最终定义一致;已有异构对象须人工核对,不能以跳过证明一致。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard (
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间',
|
||||
PRIMARY KEY (warehouse_pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '物理实托占用主键',
|
||||
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID',
|
||||
active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空',
|
||||
dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥',
|
||||
draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥',
|
||||
draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID',
|
||||
operator_id VARCHAR(128) NULL COMMENT '占用操作人ID',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态',
|
||||
expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空',
|
||||
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
|
||||
released_at DATETIME(3) NULL COMMENT '释放时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id),
|
||||
KEY idx_dispatch_physical_order (dispatch_order_id,status),
|
||||
KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status),
|
||||
CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)),
|
||||
CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL))
|
||||
) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用';
|
||||
|
||||
-- 已只读确认本地缺失:客户逻辑锁到物理锁的可空关联列及查询索引。
|
||||
-- 仅补结构,不根据历史单据推测或回填关联。
|
||||
SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='physical_lock_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN physical_lock_id BIGINT NULL COMMENT ''实际物理实托占用ID,非物理来源为空''');
|
||||
PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl;
|
||||
EXECUTE overseas_dimension_stmt;
|
||||
DEALLOCATE PREPARE overseas_dimension_stmt;
|
||||
SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND index_name='idx_dispatch_logical_physical'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD KEY idx_dispatch_logical_physical (physical_lock_id,status)');
|
||||
PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl;
|
||||
EXECUTE overseas_dimension_stmt;
|
||||
DEALLOCATE PREPARE overseas_dimension_stmt;
|
||||
SET @overseas_dimension_ddl = NULL;
|
||||
|
||||
@@ -6856,6 +6856,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_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
|
||||
@@ -1656,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history (
|
||||
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
|
||||
CREATE TABLE ll_order (
|
||||
id bigint NOT NULL COMMENT '雪花 ID',
|
||||
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
|
||||
@@ -4882,4 +4888,83 @@ CREATE TABLE ll_fleet_pickup_history (
|
||||
KEY idx_pickup_history_trip(trip_no,id)
|
||||
) 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='原排车自有业务附加运营不可变历史';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables;
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ enum BusinessFileCategory {
|
||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -71,6 +73,11 @@ enum BusinessFileCategory {
|
||||
|
||||
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
||||
void validateOwner(long tenantId) {
|
||||
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
|
||||
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||
}
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 模块定位
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ public interface MerchantWorkbenchFilterMapper {
|
||||
FROM fl_inbound_order i
|
||||
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
|
||||
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL
|
||||
AND i.process_status != 'CANCELLED' AND i.measure_review_status='APPROVED'
|
||||
AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
|
||||
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
|
||||
|
||||
+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;
|
||||
|
||||
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
|
||||
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
|
||||
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MerchantWorkbenchFilterService {
|
||||
@Resource private MerchantWorkbenchFilterMapper mapper;
|
||||
@Resource private MerchantWorkbenchFilterRepository repository;
|
||||
@Resource private UnifiedInboundQueryService unified;
|
||||
@Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights;
|
||||
|
||||
/** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */
|
||||
/** 精确详情只读进度;确认计费重待办统一使用最新已推送费用的客户确认状态。 */
|
||||
@org.springframework.transaction.annotation.Transactional(readOnly=true)
|
||||
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
|
||||
if (request.orderIds() != null) {
|
||||
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
|
||||
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
|
||||
progress(repository.progressFragments(request.tenantId(), request.orderIds())));
|
||||
}
|
||||
return new MerchantWorkbenchFilters.Response(
|
||||
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
|
||||
request.mainStatus() == null ? null : repository.byProcessStatuses(request.tenantId(),
|
||||
unified.internalStatuses(request.mainStatus())),
|
||||
weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
|
||||
progress(mapper.progressFragments(request.tenantId(), null)));
|
||||
repository.pendingFees(request.tenantId()), repository.pendingCustoms(request.tenantId()),
|
||||
progress(repository.progressFragments(request.tenantId(), null)));
|
||||
}
|
||||
|
||||
/** 将实装片段聚合为订单级履约进度。 */
|
||||
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
|
||||
var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId));
|
||||
return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> {
|
||||
|
||||
@@ -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;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
/** 柜事实和独立确认SQL入口。 */
|
||||
/** 柜事实与最新费用确认状态的只读 SQL 入口。 */
|
||||
@Mapper
|
||||
public interface WorkflowFactMapper {
|
||||
String ACTUAL="""
|
||||
@@ -13,29 +13,29 @@ public interface WorkflowFactMapper {
|
||||
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
|
||||
""";
|
||||
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
|
||||
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,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_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
|
||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
|
||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
|
||||
+"(SELECT w.fingerprint FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmation_fingerprint,"
|
||||
+"(SELECT w.confirmed_at FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmed_at "
|
||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready "
|
||||
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
|
||||
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
|
||||
/** 锁定客户自己的入仓单,串行确认与测量写入。 */
|
||||
@Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE")
|
||||
com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id);
|
||||
/** 客户订单下全部独立入仓单。 */
|
||||
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id")
|
||||
List<Long> orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
|
||||
/** 客户工作台只读取审核通过且有正计费重的来源。 */
|
||||
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id")
|
||||
List<Long> weightCandidates(@Param("tenantId") long tenantId);
|
||||
/** 成员幂等键不允许跨确认对象复用。 */
|
||||
@Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}")
|
||||
WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key);
|
||||
/** 独立记录,不修改费用或物流主状态。 */
|
||||
@Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})")
|
||||
int insert(WorkflowFacts.Confirmation value);
|
||||
/** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT i.tenant_id, i.order_id,
|
||||
CASE WHEN f.customer_confirmed_at IS NULL THEN 'WAIT_WEIGHT_CONFIRM'
|
||||
ELSE 'WEIGHT_CONFIRMED' END AS main_status
|
||||
FROM fl_inbound_order i
|
||||
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
|
||||
WHERE i.deleted=0 AND i.process_status != 'CANCELLED'
|
||||
AND i.measure_review_status='APPROVED' AND f.fee_status='SENT'
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
|
||||
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
|
||||
AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id)))
|
||||
AND i.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(@Param("ids") List<Long> ids);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
/** 本地柜事实与确认记录的持久化入口。 */
|
||||
/** 本地柜事实与费用确认状态的只读持久化入口。 */
|
||||
@Repository
|
||||
public class WorkflowFactRepository {
|
||||
private final WorkflowFactMapper mapper;
|
||||
@@ -9,14 +9,8 @@ public class WorkflowFactRepository {
|
||||
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
|
||||
/** 空候选不读取数据。 */
|
||||
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
|
||||
/** 锁定所属客户来源。 */
|
||||
public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); }
|
||||
/** 查询订单下独立来源。 */
|
||||
public List<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
|
||||
/** 客户计费重待确认候选。 */
|
||||
public List<Long> weightCandidates(long tenant) { return mapper.weightCandidates(tenant); }
|
||||
/** 读取成员确认命令。 */
|
||||
public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); }
|
||||
/** 追加确认历史。 */
|
||||
public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); }
|
||||
/** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */
|
||||
public List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(List<Long> ids) {
|
||||
return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
/** 页签事实与独立计费重确认契约。 */
|
||||
/** 订单业务页签的只读事实契约。 */
|
||||
public final class WorkflowFacts {
|
||||
/** 仅承载查询及确认契约,不允许实例化。 */
|
||||
/** 仅承载事实查询契约,不允许实例化。 */
|
||||
private WorkflowFacts() {}
|
||||
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt,
|
||||
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {}
|
||||
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
|
||||
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {}
|
||||
public record Source(long tenantId,long inboundId) {}
|
||||
public record Request(List<Source> sources) {}
|
||||
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
|
||||
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,6 +22,11 @@ public class WorkflowTabService {
|
||||
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
|
||||
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
|
||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
||||
// 确认页签复用已推送费用版本。
|
||||
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
|
||||
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
|
||||
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
||||
}
|
||||
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
||||
if(!sources.isEmpty()) {
|
||||
@@ -46,11 +51,10 @@ public class WorkflowTabService {
|
||||
}
|
||||
return List.copyOf(result.values());
|
||||
}
|
||||
/** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */
|
||||
/** 分类为可重叠的物流待办/证据集合,客户确认由最新费用版本单独提供。 */
|
||||
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
|
||||
Set<String> keys=new LinkedHashSet<>();
|
||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||
if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM");
|
||||
boolean signed="DELIVERED".equals(process);
|
||||
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
|
||||
if(!f.departed() && !signed) {
|
||||
|
||||
@@ -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:
|
||||
post:
|
||||
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
||||
description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
||||
description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -388,7 +388,7 @@ paths:
|
||||
required: [feeOrderIds, customsOrderIds, progress]
|
||||
properties:
|
||||
matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}}
|
||||
feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}}
|
||||
feeOrderIds: {type: array, description: 待客户确认最新已推送费用版本的订单ID, items: {type: integer, format: int64}}
|
||||
customsOrderIds: {type: array, items: {type: integer, format: int64}}
|
||||
progress:
|
||||
type: array
|
||||
|
||||
@@ -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}
|
||||
+2
-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");
|
||||
jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))");
|
||||
jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'");
|
||||
jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)");
|
||||
@@ -33,7 +34,7 @@ class MerchantWorkbenchFilterMapperTest {
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class);
|
||||
jdbc.update("INSERT INTO fl_inbound_order 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_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)");
|
||||
|
||||
+9
-12
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
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.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.List;
|
||||
@@ -9,25 +9,22 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MerchantWorkbenchFilterServiceTest {
|
||||
/** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */
|
||||
@Test void customerQueueUsesIndependentWeightConfirmation() {
|
||||
var mapper=mock(MerchantWorkbenchFilterMapper.class);
|
||||
var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class);
|
||||
/** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */
|
||||
@Test void customerQueueUsesPublishedFeeConfirmation() {
|
||||
var repository=mock(MerchantWorkbenchFilterRepository.class);
|
||||
var service=new MerchantWorkbenchFilterService();
|
||||
ReflectionTestUtils.setField(service,"mapper",mapper);
|
||||
ReflectionTestUtils.setField(service,"weights",weights);
|
||||
when(weights.pendingOrders(11)).thenReturn(List.of(101L));
|
||||
ReflectionTestUtils.setField(service,"repository",repository);
|
||||
when(repository.pendingFees(11)).thenReturn(List.of(101L));
|
||||
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
|
||||
assertThat(response.feeOrderIds()).containsExactly(101L);
|
||||
verify(weights).pendingOrders(11);
|
||||
verify(mapper,never()).pendingFees(anyLong());
|
||||
verify(repository).pendingFees(11);
|
||||
}
|
||||
|
||||
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
|
||||
var mapper=mock(MerchantWorkbenchFilterMapper.class);
|
||||
var mapper=mock(MerchantWorkbenchFilterRepository.class);
|
||||
var unified=mock(UnifiedInboundQueryService.class);
|
||||
var service=new MerchantWorkbenchFilterService();
|
||||
ReflectionTestUtils.setField(service,"mapper",mapper);
|
||||
ReflectionTestUtils.setField(service,"repository",mapper);
|
||||
ReflectionTestUtils.setField(service,"unified",unified);
|
||||
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
|
||||
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
|
||||
|
||||
@@ -4,8 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
@@ -15,8 +13,7 @@ import static org.mockito.Mockito.*;
|
||||
class WorkflowBoundaryTest {
|
||||
/** 真实开船来源的测试事实。 */
|
||||
private WorkflowFacts.Fact fact() {
|
||||
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
|
||||
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
|
||||
return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false);
|
||||
}
|
||||
|
||||
/** 下游漏来源或返回其他客户均整体失败。 */
|
||||
@@ -36,28 +33,4 @@ class WorkflowBoundaryTest {
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 重测同值也要求重新确认,金额没有参与摘要。 */
|
||||
@Test void repeatedMeasurementRequiresNewConfirmation() {
|
||||
var original=fact();
|
||||
var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
|
||||
original.measureCompletedAt().plusSeconds(1),true,false,false,false,
|
||||
ChargeableWeightService.fingerprint(original),LocalDateTime.now());
|
||||
assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse();
|
||||
}
|
||||
|
||||
/** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */
|
||||
@Test void confirmationUsesAuthenticatedCustomerAndOperator() {
|
||||
var service=mock(ChargeableWeightService.class);
|
||||
var controller=new MerchantChargeableWeightController(service);
|
||||
var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of());
|
||||
var request=new WorkflowFacts.Confirm("a".repeat(64));
|
||||
assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(service);
|
||||
var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of());
|
||||
controller.read(merchant,101);
|
||||
controller.confirm(merchant,1,"key",request);
|
||||
verify(service).read(11,101);
|
||||
verify(service).confirm(11,7,1,"key",request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
/** 取消回显、分柜隔离和独立计费重版本回归。 */
|
||||
/** 取消回显、分柜隔离和资料页签回归。 */
|
||||
class WorkflowBusinessTest {
|
||||
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
|
||||
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
|
||||
var time=LocalDateTime.of(2026,10,19,10,0);
|
||||
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
|
||||
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
|
||||
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
|
||||
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
|
||||
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
|
||||
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false);
|
||||
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true);
|
||||
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true);
|
||||
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true);
|
||||
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true);
|
||||
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
|
||||
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
|
||||
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
|
||||
@@ -31,12 +28,12 @@ class WorkflowBusinessTest {
|
||||
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
|
||||
}
|
||||
/** 构造独立分柜来源。 */
|
||||
private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) {
|
||||
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
|
||||
private WorkflowFacts.Fact fact(long id,boolean uploaded) {
|
||||
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false);
|
||||
}
|
||||
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
|
||||
@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 created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
|
||||
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
|
||||
@@ -47,17 +44,10 @@ class WorkflowBusinessTest {
|
||||
}
|
||||
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
|
||||
@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");
|
||||
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");
|
||||
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
|
||||
}
|
||||
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
|
||||
@Test void independentWeightVersion() {
|
||||
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
|
||||
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
|
||||
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
|
||||
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
|
||||
assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
|
||||
}
|
||||
}
|
||||
|
||||
+37
-24
@@ -5,16 +5,16 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import java.nio.file.*;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
|
||||
/** 不创建独立重量确认表,验证资料归属和最新费用版本驱动的确认流转。 */
|
||||
class WorkflowFactPersistenceTest {
|
||||
/** 测试建表不连接业务数据库。 */
|
||||
@Test void 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 db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
|
||||
db.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
|
||||
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
|
||||
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
|
||||
@@ -29,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 inbound_role VARCHAR(30)");
|
||||
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
|
||||
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
|
||||
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
|
||||
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
|
||||
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
|
||||
@@ -39,15 +38,16 @@ class WorkflowFactPersistenceTest {
|
||||
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
|
||||
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
||||
config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
|
||||
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
||||
var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
|
||||
assertThat(containers.hasActualVesselDeparture(5)).isOne();
|
||||
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
||||
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
|
||||
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
||||
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
|
||||
assertThat(service.read(22,101)).isEmpty();
|
||||
var repository=new WorkflowFactRepository(mapper);
|
||||
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
|
||||
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
|
||||
@@ -92,29 +92,42 @@ class WorkflowFactPersistenceTest {
|
||||
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
|
||||
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();
|
||||
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
|
||||
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
|
||||
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
|
||||
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
// 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。
|
||||
var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class);
|
||||
org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any()))
|
||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false))));
|
||||
var tabs=new WorkflowTabService(repository,downstream);
|
||||
var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity();
|
||||
inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||
db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)");
|
||||
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
|
||||
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||
assertThat(workbench.pendingFees(11)).containsExactly(101L);
|
||||
// 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。
|
||||
db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20");
|
||||
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"))
|
||||
.noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"));
|
||||
assertThat(workbench.pendingFees(11)).isEmpty();
|
||||
db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)");
|
||||
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||
assertThat(workbench.pendingFees(11)).isEmpty();
|
||||
db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22");
|
||||
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
|
||||
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
|
||||
assertThat(workbench.pendingFees(11)).containsExactly(101L);
|
||||
assertThat(workbench.pendingFees(22)).containsExactly(102L);
|
||||
db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1");
|
||||
assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty();
|
||||
assertThat(workbench.pendingFees(11)).isEmpty();
|
||||
db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1");
|
||||
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
|
||||
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
|
||||
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
|
||||
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
|
||||
assertThat(service.read(11,101)).hasSize(1);
|
||||
assertThat(service.read(11,103)).hasSize(1);
|
||||
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
|
||||
// 父单仅作汇总时,不重复进入业务页签。
|
||||
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
|
||||
assertThat(service.read(11,101)).isEmpty();
|
||||
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
var child=service.read(11,103).get(0);
|
||||
assertThat(child.confirmed()).isFalse();
|
||||
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
|
||||
assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
|
||||
@@ -98,6 +98,12 @@ VALUES
|
||||
(5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,0),
|
||||
(5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,0),
|
||||
(5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,0),
|
||||
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
|
||||
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
|
||||
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
|
||||
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
|
||||
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
|
||||
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0),
|
||||
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
|
||||
(5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0),
|
||||
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
|
||||
@@ -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)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
|
||||
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */
|
||||
class FleetBusinessPermissionSeedSqlContractTest {
|
||||
/** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */
|
||||
@Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception {
|
||||
String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
|
||||
String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
for(String sql:List.of(local,unified)) {
|
||||
for(var scope:List.of("owned-business","outsourced-business")) {
|
||||
long base=scope.equals("owned-business")?5107390L:5107400L;
|
||||
assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'");
|
||||
assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'");
|
||||
assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'");
|
||||
assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'");
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */
|
||||
@Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception {
|
||||
String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
|
||||
var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
|
||||
.map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList();
|
||||
assertThat(grants).hasSize(1);
|
||||
assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'",
|
||||
"role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0");
|
||||
}
|
||||
/** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */
|
||||
@Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception {
|
||||
String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
for(long id:List.of(5107400L,5107401L,5107402L)) {
|
||||
var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
|
||||
.map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList();
|
||||
assertThat(grants).hasSize(1);
|
||||
assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
|
||||
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
||||
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
||||
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
||||
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
||||
- 在途交接:`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,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
|
||||
|
||||
### 自有车辆运营视图读取边界(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`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
|
||||
@@ -32,7 +36,7 @@
|
||||
|
||||
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
|
||||
|
||||
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环、fleet不反向导入dispatch/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。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
|
||||
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
|
||||
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
|
||||
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
|
||||
const ownedBusinessReadEdges = [
|
||||
['model.OwnedBusinessModels', 'DispatchViews'],
|
||||
['service.OwnedBusinessProjection', 'DispatchContracts'],
|
||||
['service.OwnedBusinessQueryService', 'DispatchContracts'],
|
||||
['service.OwnedBusinessQueryService', 'DispatchTypes'],
|
||||
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
|
||||
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
|
||||
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
|
||||
|
||||
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
|
||||
const report = analyze();
|
||||
@@ -42,13 +50,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
|
||||
assert.equal(policyClasses.length, 3);
|
||||
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
|
||||
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']);
|
||||
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', 'shared-technical']);
|
||||
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
|
||||
assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters');
|
||||
}
|
||||
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
|
||||
assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false);
|
||||
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
|
||||
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
assert.ok(dispatchEdges.length > 0);
|
||||
@@ -58,13 +66,48 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'),
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|
||||
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
|
||||
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'),
|
||||
'dispatch must use fleet ports and guards, not its persistence mappers');
|
||||
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
assert.ok(incomingEdges.length > 0);
|
||||
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
|
||||
});
|
||||
|
||||
test('owned business reads only reviewed dispatch ports and cannot inherit persistence access', () => {
|
||||
const report = analyze();
|
||||
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
const prefix = 'com.baiyue.lastleg.fleet.ownedbusiness.';
|
||||
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
|
||||
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
|
||||
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
|
||||
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
|
||||
const edge = { source: prefix + 'service.OwnedBusinessQueryService', target: 'com.baiyue.lastleg.dispatch.' + target };
|
||||
report.classDependencies.push(edge);
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
|
||||
}
|
||||
const unreviewed = { source: 'com.baiyue.lastleg.fleet.trip.FleetTripService', target: 'com.baiyue.lastleg.dispatch.port.DispatchDetailReader' };
|
||||
report.classDependencies.push(unreviewed);
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
|
||||
});
|
||||
|
||||
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
|
||||
const report = analyze();
|
||||
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
|
||||
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
|
||||
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
|
||||
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
|
||||
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
|
||||
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
|
||||
report.classDependencies.push(edge);
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
|
||||
}
|
||||
});
|
||||
|
||||
function analyze(sourceSet = 'main') {
|
||||
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
||||
cwd: moduleRoot,
|
||||
|
||||
+9
@@ -91,6 +91,15 @@ public class LastlegOrderController {
|
||||
return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo));
|
||||
}
|
||||
|
||||
/** 按主列表完整筛选读取仓储转运数量,保持同一平台客户边界和列表权限。 */
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/warehouse-transfer-quantities")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
public ApiResponse<com.baiyue.lastleg.transfer.dto.TransferQuantityTotals> transferQuantities(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) String customerId,
|
||||
@RequestParam(required=false) String relabelNo, @Valid LastlegOrderRequests.Query query) {
|
||||
return ApiResponse.success(orderService.transferQuantityTotals(platformScope(user,customerId),query,relabelNo));
|
||||
}
|
||||
|
||||
/** Compatibility overload used by non-Spring callers and existing contract tests. */
|
||||
public ApiResponse<PageResult<LastlegOrderSummary>> list(
|
||||
AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) {
|
||||
|
||||
+59
-4
@@ -597,12 +597,19 @@ public class DeliveryTransferService {
|
||||
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
|
||||
throw conflict("换标单尚未完成客户确认或已不可出库");
|
||||
}
|
||||
if (relabelSnapshotMarks(item, selectedPosition.getId(), application.getRelabelNo()).isEmpty())
|
||||
throw conflict("换标出库指定位置或箱唛快照不完整,请核对原申请");
|
||||
List<RelabelItem> sourceItems = relabelItems.selectList(new LambdaQueryWrapper<RelabelItem>()
|
||||
.eq(RelabelItem::getApplicationId, application.getId())
|
||||
.eq(RelabelItem::getCustomerId, customerId)
|
||||
.eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId()));
|
||||
long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum();
|
||||
if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致");
|
||||
List<String> sourceMarks = sourceItems.stream().map(RelabelItem::getCartonMark)
|
||||
.filter(StringUtils::hasText).distinct().sorted().toList();
|
||||
Set<String> sourceIds = sourceItems.stream().map(source -> String.valueOf(source.getId())).collect(Collectors.toSet());
|
||||
if (sourcePackages != request.packages() || !sourceMarks.equals(expectedMarks)
|
||||
|| !sourceIds.equals(relabelSnapshotItemIds(item)))
|
||||
throw conflict("换标出库来源库存快照与任务箱数或箱唛不一致");
|
||||
int releaseIndex = 0;
|
||||
for (RelabelItem source : sourceItems) {
|
||||
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
@@ -720,6 +727,7 @@ public class DeliveryTransferService {
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
/** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */
|
||||
@Transactional
|
||||
public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo,
|
||||
String palletNo, DeliveryRequests.PdaPalletPick request) {
|
||||
@@ -769,7 +777,7 @@ public class DeliveryTransferService {
|
||||
long remaining = value(item.getPackageCount()) - value(item.getPickedPackageCount());
|
||||
if (remaining <= 0) continue;
|
||||
Map<Long, List<DeliveryResponses.PdaPickPosition>> positionsById = pdaPickPositions(
|
||||
task.getCustomerId(), boundWarehouseId, item).stream().collect(Collectors.groupingBy(
|
||||
task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()).stream().collect(Collectors.groupingBy(
|
||||
DeliveryResponses.PdaPickPosition::inventoryPositionId, LinkedHashMap::new, Collectors.toList()));
|
||||
for (Map.Entry<Long, List<DeliveryResponses.PdaPickPosition>> entry : positionsById.entrySet()) {
|
||||
if (remaining <= 0) break;
|
||||
@@ -1049,6 +1057,7 @@ public class DeliveryTransferService {
|
||||
.map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList();
|
||||
}
|
||||
|
||||
/** PDA明细展示与整托拣货使用相同的客户、仓库及来源投影。 */
|
||||
public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) {
|
||||
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
|
||||
OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"),
|
||||
@@ -1061,7 +1070,7 @@ public class DeliveryTransferService {
|
||||
value(item.getPickedPackageCount()), item.getStatus(),
|
||||
("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|
||||
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item)
|
||||
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item), item.getReturnSkuQuantity())).toList();
|
||||
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()), item.getReturnSkuQuantity())).toList();
|
||||
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
|
||||
}
|
||||
|
||||
@@ -1119,13 +1128,28 @@ public class DeliveryTransferService {
|
||||
&& value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount())));
|
||||
}
|
||||
|
||||
/** 换标使用指定实物箱及仍归本单的冻结库存;普通出库继续按可用库存和SKU匹配。 */
|
||||
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
|
||||
OutboundPalletItem item) {
|
||||
OutboundPalletItem item, String outboundType) {
|
||||
if (item.getWarehousePalletId() == null) return List.of();
|
||||
List<DeliveryResponses.PdaPickPosition> result = new ArrayList<>();
|
||||
Set<String> frozenMarks = new HashSet<>(Arrays.asList(
|
||||
Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")));
|
||||
for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) {
|
||||
if ("RELABEL_OUTBOUND".equals(outboundType)) {
|
||||
List<String> marks = relabelSnapshotMarks(item, position.getId(), null);
|
||||
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
|
||||
if (marks.isEmpty() || !customerId.equals(position.getCustomerId())
|
||||
|| !warehouseId.equals(position.getOverseasWarehouseId())
|
||||
|| !Objects.equals(item.getWarehousePalletId(), position.getPalletId())
|
||||
|| balance == null || !customerId.equals(balance.getCustomerId())
|
||||
|| !warehouseId.equals(balance.getOverseasWarehouseId())
|
||||
|| !Objects.equals(item.getInboundCode(), balance.getInboundCode())
|
||||
|| value(position.getHoldPackages()) < marks.size()) continue;
|
||||
// 本单冻结箱先在写路径校验原申请和来源任务,再释放、分配、拣货、出库;不把其他可用箱混入。
|
||||
for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0));
|
||||
continue;
|
||||
}
|
||||
long available = value(position.getAvailablePackages());
|
||||
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
|
||||
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
|
||||
@@ -1147,6 +1171,37 @@ public class DeliveryTransferService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 仅接受完整的换标出库快照;空SKU不是通配符,必须精确匹配返工后冻结的位置和全部箱唛。 */
|
||||
private List<String> relabelSnapshotMarks(OutboundPalletItem item, Long positionId, String expectedRelabelNo) {
|
||||
try {
|
||||
var refs = objectMapper.readTree(item.getOrderRefs());
|
||||
if (!"RELABEL_ORDER".equals(refs.path("sourceType").asText())
|
||||
|| !StringUtils.hasText(refs.path("relabelNo").asText())
|
||||
|| expectedRelabelNo != null && !expectedRelabelNo.equals(refs.path("relabelNo").asText())
|
||||
|| positionId == null || !String.valueOf(positionId).equals(refs.path("sourceInventoryPositionId").asText())
|
||||
|| !refs.path("items").isArray()) return List.of();
|
||||
var marks = new TreeSet<String>(); var ids = new HashSet<String>(); long packages = 0;
|
||||
for (var line : refs.path("items")) {
|
||||
String mark = line.path("cartonMark").asText().trim(), id = line.path("itemId").asText();
|
||||
long count = line.path("packages").asLong();
|
||||
if (!StringUtils.hasText(mark) || !StringUtils.hasText(id) || !ids.add(id) || !marks.add(mark) || count != 1) return List.of();
|
||||
packages += count;
|
||||
}
|
||||
var frozen = Arrays.stream(Objects.toString(item.getCartonMarks(), "").split(","))
|
||||
.map(String::trim).filter(StringUtils::hasText).collect(Collectors.toCollection(TreeSet::new));
|
||||
return packages == value(item.getPackageCount()) && marks.equals(frozen) ? List.copyOf(marks) : List.of();
|
||||
} catch (Exception ignored) { return List.of(); }
|
||||
}
|
||||
|
||||
/** 释放冻结前核对快照任务主键与本申请当前位置的真实任务集合完全相同。 */
|
||||
private Set<String> relabelSnapshotItemIds(OutboundPalletItem item) {
|
||||
try {
|
||||
var ids = new HashSet<String>();
|
||||
for (var line : objectMapper.readTree(item.getOrderRefs()).path("items")) ids.add(line.path("itemId").asText());
|
||||
return ids;
|
||||
} catch (Exception ignored) { return Set.of(); }
|
||||
}
|
||||
|
||||
private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item,
|
||||
InventoryBalance balance, String cartonMark,
|
||||
long available, long picked) {
|
||||
|
||||
+6
-2
@@ -29,12 +29,16 @@ public final class CargoDiscardResponses {
|
||||
}
|
||||
}
|
||||
public record Box(Long cartonId, String cartonMark, String palletNo, List<CandidateSku> skus) {}
|
||||
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
|
||||
public record CandidateSku(String skuCode, String productName, long availablePackages, Long availablePieces) {
|
||||
/** 无件数事实时保留未知,不按箱规推算。 */
|
||||
public CandidateSku(String skuCode, String productName, long availablePackages) { this(skuCode, productName, availablePackages, null); }
|
||||
}
|
||||
|
||||
public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
int totalPackages, BigDecimal totalFee, String currency, String status,
|
||||
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {}
|
||||
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt,
|
||||
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {}
|
||||
|
||||
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
||||
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
|
||||
|
||||
+11
@@ -19,4 +19,15 @@ public interface CargoDiscardDimensionMapper {
|
||||
WHERE i.customer_id=#{customerId} AND i.application_id=#{applicationId}
|
||||
""")
|
||||
List<String> palletNos(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
|
||||
|
||||
/** 仅读取所选转运实收箱的真实件数,其他来源保留未知。 */
|
||||
@Select("""
|
||||
SELECT CASE WHEN l.source_type='TRANSFER' THEN c.sku_items_json ELSE NULL END
|
||||
FROM ll_cargo_discard_item i
|
||||
JOIN ll_cargo_discard_carton l ON l.customer_id=i.customer_id AND l.application_id=i.application_id
|
||||
AND (l.item_id=i.id OR (i.source_type='INVENTORY' AND JSON_CONTAINS(i.carton_marks_json,JSON_QUOTE(l.carton_mark))))
|
||||
LEFT JOIN ll_warehouse_transfer_carton c ON c.id=l.carton_id AND c.customer_id=l.customer_id
|
||||
WHERE i.customer_id=#{customerId} AND i.id=#{itemId}
|
||||
""")
|
||||
List<String> selectedCartonPieces(@Param("customerId") String customerId, @Param("itemId") Long itemId);
|
||||
}
|
||||
|
||||
+4
-4
@@ -25,16 +25,16 @@ public interface CargoDiscardInventoryMapper {
|
||||
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
|
||||
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||
|
||||
/** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */
|
||||
/** 按实收托盘关联已清点箱;混装物理余额允许箱内不同 SKU,仍严格限定客户、批次、仓和托盘。 */
|
||||
@Select("""
|
||||
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type
|
||||
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type,NULL sku_items_json
|
||||
FROM ll_inbound_plan_carton c
|
||||
JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id
|
||||
JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id
|
||||
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL
|
||||
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
|
||||
UNION ALL
|
||||
SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type
|
||||
SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type,c.sku_items_json
|
||||
FROM ll_inventory_position p
|
||||
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
|
||||
JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id
|
||||
@@ -44,7 +44,7 @@ public interface CargoDiscardInventoryMapper {
|
||||
JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id
|
||||
AND c.transfer_pallet_id=tp.id AND c.status='COUNTED'
|
||||
JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id
|
||||
AND i.transfer_order_id=t.id AND COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'')
|
||||
AND i.transfer_order_id=t.id AND (b.sku_code='MIXED' OR COALESCE(i.sku_code,'')=COALESCE(b.sku_code,''))
|
||||
WHERE p.customer_id=#{customerId} AND p.id=#{positionId}
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
+8
-1
@@ -12,15 +12,22 @@ public final class CargoDiscardQuantities {
|
||||
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
|
||||
public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
|
||||
var returnBoxes = new HashSet<Long>();
|
||||
var inventoryBoxes = new HashSet<String>();
|
||||
int total = 0;
|
||||
for (var line : lines) {
|
||||
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
|
||||
if (line.getReturnCartonId() != null) {
|
||||
if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
|
||||
} else if ("INVENTORY".equals(line.getSourceType()) && line.getCartonMarksJson() != null && quantity > 0) {
|
||||
try {
|
||||
var marks = new com.fasterxml.jackson.databind.ObjectMapper().readTree(line.getCartonMarksJson());
|
||||
if (!marks.isArray() || marks.size() != quantity) throw new IllegalStateException("废弃箱唛快照与数量不一致");
|
||||
for (var mark : marks) inventoryBoxes.add(line.getInboundCode() + ":" + mark.asText());
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw new IllegalStateException("废弃箱唛快照无法读取", e); }
|
||||
} else {
|
||||
total = Math.addExact(total, quantity);
|
||||
}
|
||||
}
|
||||
return Math.addExact(total, returnBoxes.size());
|
||||
return Math.addExact(Math.addExact(total, returnBoxes.size()), inventoryBoxes.size());
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -9,4 +9,5 @@ public class CargoDiscardStockCartonRow {
|
||||
private String cartonMark;
|
||||
private String skuCode;
|
||||
private String sourceType;
|
||||
private String skuItemsJson;
|
||||
}
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ public final class PdaCargoDiscardDtos {
|
||||
}
|
||||
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
|
||||
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
|
||||
Integer actualPackages, String status, String quantityUnit) { }
|
||||
Integer actualPackages, String status, String quantityUnit,
|
||||
boolean actualQuantityEditable) { }
|
||||
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
|
||||
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
|
||||
String scanMode, List<String> cartonMarks) { }
|
||||
|
||||
+44
-35
@@ -1,17 +1,11 @@
|
||||
package com.baiyue.lastleg.discard.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
|
||||
import com.baiyue.lastleg.discard.entity.*;
|
||||
import com.baiyue.lastleg.discard.mapper.*;
|
||||
import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos;
|
||||
import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService;
|
||||
import com.baiyue.lastleg.discard.service.CargoDiscardService;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -24,28 +18,24 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class PdaCargoDiscardService {
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
|
||||
@Resource private CargoDiscardApplicationMapper applications;
|
||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository repository;
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper cartons;
|
||||
@Resource private DropshipOrderProfileMapper profiles;
|
||||
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
|
||||
@Resource private CargoDiscardService discardService;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
|
||||
|
||||
/** 只读取绑定仓已审核申请的实际托盘任务。 */
|
||||
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
|
||||
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||
.eq(CargoDiscardApplication::getOverseasWarehouseId, String.valueOf(identity.overseasWarehouseId()))
|
||||
.eq(CargoDiscardApplication::getStatus, CargoDiscardService.APPROVED)
|
||||
.orderByAsc(CargoDiscardApplication::getCreatedAt))
|
||||
return repository.approved(String.valueOf(identity.overseasWarehouseId()))
|
||||
.stream().flatMap(a -> palletTasks(a).stream()).toList();
|
||||
}
|
||||
|
||||
/** 扫码读取当前仓库唯一可操作任务。 */
|
||||
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) {
|
||||
return resolve(identity, palletNo, null);
|
||||
}
|
||||
|
||||
/** 扫码时限定已选择申请,防止同托盘多任务误操作。 */
|
||||
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) {
|
||||
var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim()))
|
||||
.filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList();
|
||||
@@ -54,10 +44,11 @@ public class PdaCargoDiscardService {
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务"));
|
||||
}
|
||||
|
||||
/** 仓库按无箱唛对应任务的实点数结算,其余任务继续核平所选箱。 */
|
||||
@Transactional
|
||||
public PdaCargoDiscardDtos.PalletTask confirm(PdaWarehouseIdentity identity, String discardNo,
|
||||
String palletNo, PdaCargoDiscardDtos.Confirm request) {
|
||||
CargoDiscardApplication application = applications.findNoByWarehouse(discardNo,
|
||||
CargoDiscardApplication application = repository.lockApplication(discardNo,
|
||||
String.valueOf(identity.overseasWarehouseId()));
|
||||
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
|
||||
boolean terminal = CargoDiscardService.PDA_COMPLETED.equals(application.getStatus())
|
||||
@@ -71,8 +62,12 @@ public class PdaCargoDiscardService {
|
||||
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
|
||||
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
|
||||
}
|
||||
if (task.skus().stream().anyMatch(line -> !Objects.equals(actuals.get(line.itemId()), line.plannedPackages())))
|
||||
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
|
||||
for (var line : task.skus()) {
|
||||
int actual = actuals.get(line.itemId());
|
||||
if (actual < 0 || actual > line.plannedPackages()) throw conflict("实际废弃数量不能超过申请预留数量");
|
||||
if (!line.actualQuantityEditable() && actual != line.plannedPackages())
|
||||
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
|
||||
}
|
||||
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
|
||||
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
|
||||
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
|
||||
@@ -80,7 +75,7 @@ public class PdaCargoDiscardService {
|
||||
}
|
||||
List<CargoDiscardItem> palletItems = new ArrayList<>();
|
||||
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
||||
CargoDiscardItem item = items.selectById(line.itemId());
|
||||
CargoDiscardItem item = repository.item(line.itemId());
|
||||
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|
||||
|| !Objects.equals(application.getId(), item.getApplicationId())
|
||||
|| !physicalCode(application, item).equals(task.palletNo()))
|
||||
@@ -90,14 +85,14 @@ public class PdaCargoDiscardService {
|
||||
throw conflict("已完成托盘的实际数量不能修改");
|
||||
palletItems.add(item);
|
||||
}
|
||||
if (terminal && items.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
if (terminal && repository.items(application.getCustomerId(), application.getId()).stream()
|
||||
.anyMatch(item -> !"COMPLETED".equals(item.getPdaStatus())
|
||||
|| !application.getCustomerId().equals(item.getCustomerId())
|
||||
|| !Objects.equals(application.getId(), item.getApplicationId())))
|
||||
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
|
||||
photoService.confirm(application, task.palletNo(), request.photoFileIds(),
|
||||
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
|
||||
// A completed pallet is an immutable result, including the final pallet after application completion.
|
||||
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
|
||||
if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
|
||||
if (terminal) throw conflict("废弃终态不能执行未完成托盘");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -105,20 +100,25 @@ public class PdaCargoDiscardService {
|
||||
if ("COMPLETED".equals(item.getPdaStatus())) {
|
||||
continue;
|
||||
}
|
||||
List<CargoDiscardCarton> links = cartons.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
List<CargoDiscardCarton> links = repository.cartons(application.getCustomerId(), application.getId()).stream()
|
||||
.filter(c -> c.getItemId().equals(item.getId())).toList();
|
||||
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
|
||||
actuals.get(item.getId()), identity.operatorKey(), now);
|
||||
// 未知箱唛对应关系时只按当前仓位冻结桶结算,不猜测被废弃的具体箱唛。
|
||||
if ("INVENTORY".equals(item.getSourceType()) && stockService.actualQuantityEditable(item)) {
|
||||
stockService.discard(application.getCustomerId(), identity.operatorKey(), application, item, actuals.get(item.getId()));
|
||||
} else {
|
||||
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
|
||||
actuals.get(item.getId()), identity.operatorKey(), now);
|
||||
}
|
||||
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
|
||||
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item);
|
||||
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
|
||||
}
|
||||
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(items::selectById)
|
||||
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
|
||||
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
|
||||
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
|
||||
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
if (repository.items(application.getCustomerId(), application.getId()).stream()
|
||||
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
|
||||
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
|
||||
items.listApplication(application.getCustomerId(), application.getId()), true);
|
||||
repository.items(application.getCustomerId(), application.getId()), true);
|
||||
application.setTotalPackages(actualTotal);
|
||||
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
|
||||
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
||||
@@ -130,6 +130,7 @@ public class PdaCargoDiscardService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
/** 当前申请行锁内保存现场照片,已完成托盘禁止补写。 */
|
||||
public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo,
|
||||
long operatorUserId, org.springframework.web.multipart.MultipartFile file) {
|
||||
CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true);
|
||||
@@ -140,19 +141,22 @@ public class PdaCargoDiscardService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
/** 读取绑定仓当前托盘的照片。 */
|
||||
public List<CargoDiscardResponses.Photo> photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) {
|
||||
return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
/** 下载当前申请和托盘拥有的照片。 */
|
||||
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(
|
||||
PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) {
|
||||
return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false);
|
||||
}
|
||||
|
||||
/** 照片访问必须同时满足绑定仓和申请托盘范围。 */
|
||||
private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) {
|
||||
var warehouseId = String.valueOf(identity.overseasWarehouseId());
|
||||
var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId);
|
||||
var application = lock ? repository.lockApplication(discardNo, warehouseId) : repository.readApplication(discardNo, warehouseId);
|
||||
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
|
||||
if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim())))
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单");
|
||||
@@ -162,13 +166,13 @@ public class PdaCargoDiscardService {
|
||||
private static final String LOOSE = "散箱";
|
||||
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
|
||||
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
|
||||
var allItems = items.listApplication(application.getCustomerId(), application.getId());
|
||||
var allItems = repository.items(application.getCustomerId(), application.getId());
|
||||
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
|
||||
return grouped.entrySet().stream().map(entry -> {
|
||||
CargoDiscardItem first = entry.getValue().get(0);
|
||||
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
|
||||
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
|
||||
isReturnItem(i) ? "件" : "箱")).toList();
|
||||
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
|
||||
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
||||
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
||||
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
|
||||
@@ -180,6 +184,7 @@ public class PdaCargoDiscardService {
|
||||
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
|
||||
}).toList();
|
||||
}
|
||||
/** 相关托盘只从客户库存事实读取,不从请求推断。 */
|
||||
private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) {
|
||||
String code;
|
||||
if ("INVENTORY".equals(item.getSourceType())) {
|
||||
@@ -187,31 +192,35 @@ public class PdaCargoDiscardService {
|
||||
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库");
|
||||
code = position.getPalletNo();
|
||||
} else if (isReturnItem(item)) {
|
||||
var carton = returnCartons.selectById(item.getReturnCartonId());
|
||||
var carton = repository.returnCarton(item.getReturnCartonId());
|
||||
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在");
|
||||
code = carton.getPalletCode();
|
||||
} else {
|
||||
var profile = profiles.selectById(item.getProfileId());
|
||||
var profile = repository.profile(item.getProfileId());
|
||||
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在");
|
||||
code = profile.getPalletNo();
|
||||
}
|
||||
return code == null || code.isBlank() ? LOOSE : code.trim();
|
||||
}
|
||||
/** 仅返回保存的真实箱唛,未知对应关系不补造箱码。 */
|
||||
private List<String> cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) {
|
||||
if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item);
|
||||
if (isReturnItem(item)) {
|
||||
var carton = returnCartons.selectById(item.getReturnCartonId());
|
||||
var carton = repository.returnCarton(item.getReturnCartonId());
|
||||
String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton);
|
||||
return mark == null ? List.of() : List.of(mark);
|
||||
}
|
||||
return cartons.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
return repository.cartons(application.getCustomerId(), application.getId()).stream()
|
||||
.filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList();
|
||||
}
|
||||
/** 退货库存沿用独立按件结算规则。 */
|
||||
private boolean isReturnItem(CargoDiscardItem item) {
|
||||
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
|
||||
}
|
||||
/** 取首个有效业务展示值。 */
|
||||
private static String firstNonBlank(String first, String second) {
|
||||
return first != null && !first.isBlank() ? first : second;
|
||||
}
|
||||
/** 返回可恢复业务冲突。 */
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
|
||||
+21
@@ -12,8 +12,29 @@ import java.util.List;
|
||||
public class CargoDiscardDimensionRepository {
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardDimensionMapper dimensions;
|
||||
@Resource private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
||||
/** 读取一票客户隔离明细。 */
|
||||
public List<CargoDiscardItem> items(String customer, Long application) { return items.listApplication(customer, application); }
|
||||
/** 读取一票托盘身份,保留无事实行。 */
|
||||
public List<String> pallets(String customer, Long application) { return dimensions.palletNos(customer, application); }
|
||||
/** 完整箱级事实才可提供件数,缺箱或未知件数不得固定换算。 */
|
||||
public Long pieces(String customer, CargoDiscardItem item) {
|
||||
var values = dimensions.selectedCartonPieces(customer, item.getId());
|
||||
if (values.isEmpty() || values.size() != item.getDiscardPackages()) return null;
|
||||
long total = 0;
|
||||
for (var value : values) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
var contents = objectMapper.readTree(value);
|
||||
if (!contents.isArray()) return null;
|
||||
long selected = 0;
|
||||
for (var content : contents) {
|
||||
if (item.getSkuCode().equals(content.path("skuCode").asText())) selected += content.path("quantity").asLong();
|
||||
}
|
||||
if (selected <= 0) return null;
|
||||
total += selected;
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { return null; }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.lastleg.discard.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.discard.entity.*;
|
||||
import com.baiyue.lastleg.discard.mapper.*;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 封装 PDA 废弃任务读取和作业快照持久化,保留绑定仓与申请行锁。 */
|
||||
@Repository
|
||||
public class CargoDiscardPdaRepository {
|
||||
@Resource private CargoDiscardApplicationMapper applications;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper cartons;
|
||||
@Resource private DropshipOrderProfileMapper profiles;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
|
||||
/** 按已验证绑定仓读取已审核作业,客户归属由申请事实保留。 */
|
||||
public List<CargoDiscardApplication> approved(String warehouseId) {
|
||||
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||
.eq(CargoDiscardApplication::getOverseasWarehouseId, warehouseId)
|
||||
.eq(CargoDiscardApplication::getStatus, "APPROVED")
|
||||
.orderByAsc(CargoDiscardApplication::getCreatedAt));
|
||||
}
|
||||
/** 写操作按申请串行,保证扣减、释放、实点结果及终态同事务。 */
|
||||
public CargoDiscardApplication lockApplication(String no, String warehouse) { return applications.findNoByWarehouse(no, warehouse); }
|
||||
/** 照片只读入口仍限制绑定仓。 */
|
||||
public CargoDiscardApplication readApplication(String no, String warehouse) { return applications.readNoByWarehouse(no, warehouse); }
|
||||
/** 按申请所属客户读取全部明细。 */
|
||||
public List<CargoDiscardItem> items(String customer, Long application) { return items.listApplication(customer, application); }
|
||||
/** 读取明细后由服务复验客户与申请。 */
|
||||
public CargoDiscardItem item(Long id) { return items.selectById(id); }
|
||||
/** 保存一次实际作业结果。 */
|
||||
public void saveItem(CargoDiscardItem item) { items.updateById(item); }
|
||||
/** 按客户和申请读取已选箱快照。 */
|
||||
public List<CargoDiscardCarton> cartons(String customer, Long application) { return cartons.listApplication(customer, application); }
|
||||
/** 读取原托盘档案供服务核验归属。 */
|
||||
public DropshipOrderProfile profile(Long id) { return profiles.selectById(id); }
|
||||
/** 读取退货箱档案供服务核验归属。 */
|
||||
public ReturnInventoryCarton returnCarton(Long id) { return returnCartons.selectById(id); }
|
||||
}
|
||||
+3
-2
@@ -11,9 +11,10 @@ import org.springframework.stereotype.Repository;
|
||||
public class CargoDiscardQuantityRepository {
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
|
||||
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */
|
||||
/** 完工后列表和详情按实际箱数展示,作业前保留申请预留数量。 */
|
||||
public int boxes(CargoDiscardApplication application) {
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
|
||||
boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
|
||||
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed);
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.discard.repository;
|
||||
|
||||
import com.baiyue.lastleg.discard.entity.*;
|
||||
import com.baiyue.lastleg.discard.mapper.*;
|
||||
import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow;
|
||||
import com.baiyue.lastleg.inventory.entity.*;
|
||||
import com.baiyue.lastleg.inventory.mapper.*;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 封装普通库存废弃的客户隔离读取、锁箱和申请快照保存。 */
|
||||
@Repository
|
||||
public class CargoDiscardStockRepository {
|
||||
@Resource private CargoDiscardInventoryMapper stock;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper links;
|
||||
|
||||
/** 读取客户当前可用且没有转运占用的库存仓位。 */
|
||||
public List<InventoryPosition> candidates(String customer, String keyword) { return stock.genericPositions(customer, keyword); }
|
||||
/** 读取余额,服务继续校验其客户与仓库归属。 */
|
||||
public InventoryBalance balance(Long id) { return balances.selectById(id); }
|
||||
/** 读取客户指定仓位。 */
|
||||
public InventoryPosition position(String customer, Long id) { return positions.findTenantPosition(customer, id); }
|
||||
/** 锁定源仓位以串行创建预留。 */
|
||||
public InventoryPosition lockPosition(String customer, Long id) { return stock.lockGenericPosition(customer, id); }
|
||||
/** 读取已有的真实箱唛与托盘对应事实。 */
|
||||
public List<CargoDiscardStockCartonRow> cartons(String customer, Long position) { return stock.genericCartons(customer, position); }
|
||||
/** 排除须走一件代发逐箱预留的仓位。 */
|
||||
public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; }
|
||||
/** 读取散箱实收流水中的箱码快照。 */
|
||||
public List<String> looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); }
|
||||
/** 保存废弃申请的库存来源明细。 */
|
||||
public void saveItem(CargoDiscardItem item) { items.insert(item); }
|
||||
/** 保存申请所选实物箱快照。 */
|
||||
public void saveCarton(CargoDiscardCarton carton) { links.insert(carton); }
|
||||
/** 读取申请全部所选箱,服务按当前明细筛选。 */
|
||||
public List<CargoDiscardCarton> selectedCartons(String customer, Long application) { return links.listApplication(customer, application); }
|
||||
/** 读取同申请各 SKU 的仓位和箱唛快照,用于按物理仓位消除重复库存命令。 */
|
||||
public List<CargoDiscardItem> applicationItems(String customer, Long application) { return items.listApplication(customer, application); }
|
||||
/** 普通实收箱用原状态条件更新,防止并发占用。 */
|
||||
public int changeInboundCarton(String customer, Long id, String from, String to) { return stock.changeGenericCartonStatus(customer, id, from, to); }
|
||||
/** 转运实收箱保留已清点状态语义。 */
|
||||
public int changeTransferCarton(String customer, Long id, String from, String to) { return stock.changeTransferCartonStatus(customer, id, from, to); }
|
||||
}
|
||||
+12
-3
@@ -58,6 +58,7 @@ public class CargoDiscardOutboundService {
|
||||
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
|
||||
var palletCodes = new java.util.HashSet<String>();
|
||||
var returnBoxIds = new java.util.HashSet<Long>();
|
||||
var inventoryBoxMarks = new java.util.HashSet<String>();
|
||||
for (var line : lines) {
|
||||
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
|
||||
String palletNo;
|
||||
@@ -88,7 +89,7 @@ public class CargoDiscardOutboundService {
|
||||
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
|
||||
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
|
||||
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
|
||||
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
|
||||
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks));
|
||||
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
|
||||
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
|
||||
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
|
||||
@@ -99,11 +100,13 @@ public class CargoDiscardOutboundService {
|
||||
}
|
||||
|
||||
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
|
||||
/** 同步废弃实物出库投影,混装 SKU 明细不能重复累计物理箱。 */
|
||||
public void sync(CargoDiscardApplication application) {
|
||||
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
|
||||
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
var returnBoxIds = new java.util.HashSet<Long>();
|
||||
var inventoryBoxMarks = new java.util.HashSet<String>();
|
||||
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
|
||||
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
|
||||
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
|
||||
@@ -114,11 +117,11 @@ public class CargoDiscardOutboundService {
|
||||
throw conflict("废弃明细关联退货箱不存在");
|
||||
snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton));
|
||||
}
|
||||
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
|
||||
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks);
|
||||
snapshot.setPackageCount(boxes);
|
||||
if ("COMPLETED".equals(line.getPdaStatus())) {
|
||||
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
|
||||
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
|
||||
: "INVENTORY".equals(line.getSourceType()) && !stockService.cartonMarks(line).isEmpty() ? boxes : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
|
||||
snapshot.setStatus("PICKED");
|
||||
}
|
||||
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
|
||||
@@ -134,5 +137,11 @@ public class CargoDiscardOutboundService {
|
||||
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
|
||||
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
||||
}
|
||||
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
|
||||
private int physicalBoxes(com.baiyue.lastleg.discard.entity.CargoDiscardItem line, java.util.Set<String> seen) {
|
||||
if (!"INVENTORY".equals(line.getSourceType())) return line.getDiscardPackages();
|
||||
var marks = stockService.cartonMarks(line);
|
||||
return marks.isEmpty() ? line.getDiscardPackages() : (int)marks.stream().filter(mark -> seen.add(line.getInboundCode() + ":" + mark)).count();
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
|
||||
+14
-4
@@ -165,7 +165,10 @@ public class CargoDiscardService {
|
||||
if (input.cartonIds() != null) {
|
||||
if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致");
|
||||
String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP";
|
||||
for (Long cartonId : input.cartonIds()) if (!requestedBoxes.add(source + ":" + cartonId)) throw badRequest("同一个箱子不能重复废弃");
|
||||
for (Long cartonId : input.cartonIds()) {
|
||||
boolean added = requestedBoxes.add(source + ":" + cartonId);
|
||||
if (!added && input.inventoryPositionId() == null) throw badRequest("同一个箱子不能重复废弃");
|
||||
}
|
||||
}
|
||||
int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0);
|
||||
if (sources != 1) {
|
||||
@@ -202,7 +205,7 @@ public class CargoDiscardService {
|
||||
throw conflict("One cargo discard application cannot span operating warehouses");
|
||||
}
|
||||
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
|
||||
if (!returnSource) total = Math.addExact(total, input.discardPackages());
|
||||
if (!returnSource && input.cartonIds() == null) total = Math.addExact(total, input.discardPackages());
|
||||
}
|
||||
|
||||
for (Long cartonId : returnCartonCache.keySet()) {
|
||||
@@ -214,6 +217,7 @@ public class CargoDiscardService {
|
||||
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
|
||||
}
|
||||
total = Math.addExact(total, returnCartonCache.size());
|
||||
total = Math.addExact(total, requestedBoxes.size());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
@@ -236,9 +240,10 @@ public class CargoDiscardService {
|
||||
// 废弃申请独立承载废弃流程,不创建尾程订单。
|
||||
|
||||
Set<Long> reservedReturnCartons = new LinkedHashSet<>();
|
||||
stockService.reserveAll(customerId, operatorId, application, request.items(), now);
|
||||
for (CargoDiscardRequests.Item input : request.items()) {
|
||||
if (input.inventoryPositionId() != null) {
|
||||
stockService.reserve(customerId, operatorId, application, input, now);
|
||||
// 普通库存已按完整混装箱批量预留。
|
||||
} else if (input.returnCartonId() != null) {
|
||||
reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now,
|
||||
reservedReturnCartons);
|
||||
@@ -513,10 +518,15 @@ public class CargoDiscardService {
|
||||
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
|
||||
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
|
||||
int boxes = quantities.boxes(value);
|
||||
var lines = items.listApplication(value.getCustomerId(), value.getId());
|
||||
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
|
||||
int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false);
|
||||
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
|
||||
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
|
||||
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
|
||||
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
|
||||
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual);
|
||||
}
|
||||
|
||||
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
||||
|
||||
+8
-2
@@ -32,8 +32,14 @@ public class CargoDiscardStatisticsService {
|
||||
for (var line : lines) {
|
||||
if (line.getSkuCode() != null && !line.getSkuCode().isBlank()) skus.add(line.getSkuCode());
|
||||
if (line.getReturnCartonId() == null) {
|
||||
piecesKnown = false;
|
||||
if (line.getActualDiscardPackages() != null) actualPiecesKnown = false;
|
||||
var selectedPieces = dimensions.pieces(application.getCustomerId(), line);
|
||||
if (selectedPieces == null) piecesKnown = false;
|
||||
else pieces += selectedPieces;
|
||||
if (line.getActualDiscardPackages() != null) {
|
||||
if (line.getActualDiscardPackages() == 0) { /* 实际未废弃商品为零,不推测箱规。 */ }
|
||||
else if (selectedPieces != null && line.getActualDiscardPackages() == line.getDiscardPackages()) actualPieces += selectedPieces;
|
||||
else actualPiecesKnown = false;
|
||||
}
|
||||
} else {
|
||||
pieces += line.getDiscardPackages();
|
||||
if (line.getActualDiscardPackages() != null) actualPieces += line.getActualDiscardPackages();
|
||||
|
||||
+143
-41
@@ -2,11 +2,9 @@ package com.baiyue.lastleg.discard.service;
|
||||
|
||||
import com.baiyue.lastleg.discard.dto.*;
|
||||
import com.baiyue.lastleg.discard.entity.*;
|
||||
import com.baiyue.lastleg.discard.mapper.*;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.entity.*;
|
||||
import com.baiyue.lastleg.inventory.mapper.*;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -16,129 +14,233 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Stock eligibility is based on physical available positions, independent of the source document type. */
|
||||
/** 根据实际可用仓位校验废弃库存,精确选箱与无对应关系实点分别结算。 */
|
||||
@Service
|
||||
public class CargoDiscardStockService {
|
||||
@Resource private CargoDiscardInventoryMapper stock;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
|
||||
@Resource private InventoryCommandService commands;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper links;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
|
||||
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
|
||||
return stock.genericPositions(customer, keyword).stream().map(position -> {
|
||||
var balance = balances.selectById(position.getBalanceId());
|
||||
return repository.candidates(customer, keyword).stream().map(position -> {
|
||||
var balance = repository.balance(position.getBalanceId());
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
|
||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||
var cartons = stock.genericCartons(customer, position.getId());
|
||||
boolean complete = cartons.size() == available && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank())
|
||||
&& cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size();
|
||||
var cartons = repository.cartons(customer, position.getId());
|
||||
boolean complete = completeCartons(cartons, available);
|
||||
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
|
||||
List.of(new CargoDiscardResponses.CandidateSku(c.getSkuCode(), balance.getProductNameZh(), 1)))).toList() : List.<CargoDiscardResponses.Box>of();
|
||||
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
|
||||
return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null,
|
||||
balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(),
|
||||
balance.getOverseasWarehouseName(), position.getCreatedAt(), available,
|
||||
List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)),
|
||||
complete ? summarizeSkus(cartons, balance) : List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)),
|
||||
position.getId(), position.getPalletNo(), boxes, complete,
|
||||
complete ? null : "当前库存缺少完整箱唛与托盘对应记录,可按全部库存废弃");
|
||||
complete ? null : "缺少完整箱唛与托盘对应记录,将预留所选库存,由仓库核对相关托盘并填写实际废弃箱数,未废弃部分释放");
|
||||
}).filter(c -> c.availablePackages() > 0).toList();
|
||||
}
|
||||
|
||||
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
|
||||
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
|
||||
var position = position(customer, request.inventoryPositionId());
|
||||
var balance = balances.selectById(position.getBalanceId());
|
||||
var balance = repository.balance(position.getBalanceId());
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())
|
||||
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
|
||||
throw conflict("库存不属于当前客户或仓库");
|
||||
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
|
||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
|
||||
if (request.cartonIds() != null) {
|
||||
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
|
||||
throw conflict("废弃箱子数量与所选箱子不一致或重复");
|
||||
var cartons = stock.genericCartons(customer, position.getId());
|
||||
var cartons = repository.cartons(customer, position.getId());
|
||||
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
|
||||
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
|
||||
} else if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
|
||||
if (stock.isDropshipPosition(customer, position.getId()) > 0) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
|
||||
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
|
||||
} else {
|
||||
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
|
||||
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
|
||||
}
|
||||
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
|
||||
return balance;
|
||||
}
|
||||
|
||||
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
|
||||
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
|
||||
if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
|
||||
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
|
||||
var balance = validate(customer, request);
|
||||
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>());
|
||||
}
|
||||
|
||||
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
|
||||
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
|
||||
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
|
||||
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
|
||||
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
|
||||
});
|
||||
var balances = new HashMap<Long, InventoryBalance>();
|
||||
var cartons = new HashMap<Long, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>>();
|
||||
var selectedSkus = new HashMap<String, Set<String>>();
|
||||
for (var request : stocks) {
|
||||
balances.put(request.inventoryPositionId(), validate(customer, request));
|
||||
var facts = repository.cartons(customer, request.inventoryPositionId());
|
||||
cartons.put(request.inventoryPositionId(), facts);
|
||||
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
|
||||
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
|
||||
}
|
||||
for (var request : stocks) for (var carton : cartons.get(request.inventoryPositionId())) {
|
||||
if (request.cartonIds() != null && !request.cartonIds().contains(carton.getId())) continue;
|
||||
var required = contents(carton, null).stream().map(CargoDiscardResponses.CandidateSku::skuCode).collect(java.util.stream.Collectors.toSet());
|
||||
if (required.size() > 1 && request.cartonIds() == null) throw conflict("混装箱必须选择具体箱子及箱内全部 SKU 库存");
|
||||
if (!selectedSkus.get(carton.getSourceType() + ":" + carton.getId()).containsAll(required)) throw conflict("混装箱必须选择箱内全部 SKU 库存");
|
||||
}
|
||||
var lockedBoxes = new HashSet<String>();
|
||||
var heldBoxes = new HashSet<String>();
|
||||
for (var request : stocks) reserveValidated(customer, operator, application, request, at,
|
||||
balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes);
|
||||
}
|
||||
|
||||
/** 使用本事务变更箱状态前的完整事实,为每个 SKU 分别预留并保存相同箱唛快照。 */
|
||||
private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at,
|
||||
InventoryBalance balance, List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> availableCartons, Set<String> lockedBoxes, Set<String> heldBoxes) {
|
||||
var position = position(customer, request.inventoryPositionId());
|
||||
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
|
||||
line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId());
|
||||
line.setInboundCode(balance.getInboundCode()); line.setSkuCode(sku(balance)); line.setProductName(balance.getProductNameZh());
|
||||
line.setAvailablePackagesSnapshot(Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())))); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at);
|
||||
var availableCartons = stock.genericCartons(customer, position.getId());
|
||||
line.setInboundCode(balance.getInboundCode()); line.setSkuCode(request.skuCode().trim()); line.setProductName(sku(balance).equals(line.getSkuCode()) ? balance.getProductNameZh() : null);
|
||||
line.setAvailablePackagesSnapshot(availableCartons.isEmpty() ? Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()))) : availableCartons.size()); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at);
|
||||
var selected = request.cartonIds() == null
|
||||
? (availableCartons.size() == request.discardPackages() ? availableCartons : List.<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>of())
|
||||
? (completeCartons(availableCartons, request.discardPackages()) ? availableCartons : List.<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>of())
|
||||
: availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList();
|
||||
if (!selected.isEmpty()) {
|
||||
for (var carton : selected) {
|
||||
if (!lockedBoxes.add(carton.getSourceType() + ":" + carton.getId())) continue;
|
||||
if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选");
|
||||
var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId());
|
||||
link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link);
|
||||
link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); repository.saveCarton(link);
|
||||
}
|
||||
snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList());
|
||||
line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages()));
|
||||
} else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) {
|
||||
var marks = stock.looseCartonMarks(customer, position.getId());
|
||||
var marks = repository.looseMarks(customer, position.getId());
|
||||
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
|
||||
snapshotMarks(line, marks);
|
||||
}
|
||||
commands.hold(customer, operator, move(application, line, request.discardPackages(), "HOLD"));
|
||||
items.insert(line);
|
||||
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count();
|
||||
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD"));
|
||||
repository.saveItem(line);
|
||||
}
|
||||
|
||||
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
|
||||
private List<CargoDiscardResponses.CandidateSku> summarizeSkus(List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons, InventoryBalance balance) {
|
||||
var boxes = new LinkedHashMap<String, Long>(); var pieces = new HashMap<String, Long>(); var unknown = new HashSet<String>();
|
||||
for (var carton : cartons) for (var content : contents(carton, null)) {
|
||||
boxes.merge(content.skuCode(), 1L, Math::addExact);
|
||||
if (content.availablePieces() == null) unknown.add(content.skuCode()); else pieces.merge(content.skuCode(), content.availablePieces(), Math::addExact);
|
||||
}
|
||||
return boxes.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), sku(balance).equals(e.getKey()) ? balance.getProductNameZh() : null,
|
||||
e.getValue(), unknown.contains(e.getKey()) ? null : pieces.get(e.getKey()))).toList();
|
||||
}
|
||||
|
||||
/** 箱数、箱唛和唯一性均完整时,才允许将库存对应到具体箱子。 */
|
||||
private boolean completeCartons(List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons, long count) {
|
||||
return cartons.size() == count && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank())
|
||||
&& cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size();
|
||||
}
|
||||
/** 转运实收 JSON 提供完整 SKU 与件数;无 JSON 的单 SKU 来源保留箱数单位。 */
|
||||
private List<CargoDiscardResponses.CandidateSku> contents(com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton, String productName) {
|
||||
if (carton.getSkuItemsJson() == null || carton.getSkuItemsJson().isBlank())
|
||||
return List.of(new CargoDiscardResponses.CandidateSku(carton.getSkuCode(), productName, 1));
|
||||
try {
|
||||
var values = objectMapper.readTree(carton.getSkuItemsJson());
|
||||
if (!values.isArray()) throw conflict("箱内 SKU 事实不完整");
|
||||
var quantities = new LinkedHashMap<String, Long>();
|
||||
for (var value : values) {
|
||||
String code = value.path("skuCode").asText().trim(); long pieces = value.path("quantity").asLong();
|
||||
if (code.isEmpty() || pieces <= 0) throw conflict("箱内 SKU 事实不完整");
|
||||
quantities.merge(code, pieces, Math::addExact);
|
||||
}
|
||||
if (quantities.isEmpty()) throw conflict("箱内 SKU 事实不完整");
|
||||
return quantities.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), Objects.equals(e.getKey(), carton.getSkuCode()) ? productName : null, 1, e.getValue())).toList();
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("箱内 SKU 事实无法读取"); }
|
||||
}
|
||||
|
||||
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
|
||||
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
|
||||
changeSelectedCartons(customer, application, line, "AVAILABLE");
|
||||
commands.release(customer, application.getCreatedBy(), move(application, line, line.getDiscardPackages(), "RELEASE"));
|
||||
int physical = physicalPackages(customer, application, line);
|
||||
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
|
||||
}
|
||||
|
||||
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
|
||||
if (actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出");
|
||||
changeSelectedCartons(customer, application, line, "DISCARDED");
|
||||
commands.discardHeld(customer, operator, move(application, line, actual, "DISCARD"));
|
||||
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
|
||||
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
|
||||
throw conflict("必须完成本次申请所选全部箱子,不能漏出");
|
||||
// 已选择具体箱子的作业仍按箱唛结算,不能按数量猜测哪些箱已被废弃。
|
||||
if (!actualQuantityEditable(line)) changeSelectedCartons(customer, application, line, "DISCARDED");
|
||||
int physical = actualQuantityEditable(line) ? actual : physicalPackages(customer, application, line);
|
||||
if (physical > 0) commands.discardHeld(customer, operator, move(application, line, physical, "DISCARD"));
|
||||
int remaining = line.getDiscardPackages() - actual;
|
||||
if (remaining > 0) commands.release(customer, operator, move(application, line, remaining, "ACTUAL_RELEASE"));
|
||||
}
|
||||
|
||||
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
|
||||
if ("TRANSFER".equals(source)) return stock.changeTransferCartonStatus(customer, cartonId, from, to);
|
||||
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
|
||||
return stock.changeGenericCartonStatus(customer, cartonId, from, to);
|
||||
/** 同仓位同箱只由首个 SKU 明细执行库存命令;其他 SKU 保存件数事实,不重复扣物理箱桶。 */
|
||||
private int physicalPackages(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
|
||||
var marks = cartonMarks(line);
|
||||
if (marks.isEmpty()) return line.getDiscardPackages();
|
||||
var previous = new HashSet<String>();
|
||||
for (var other : repository.applicationItems(customer, application.getId())) {
|
||||
if (Objects.equals(other.getInventoryPositionId(), line.getInventoryPositionId()) && other.getId() != null && line.getId() != null && other.getId() < line.getId()) previous.addAll(cartonMarks(other));
|
||||
}
|
||||
return (int)marks.stream().filter(mark -> !previous.contains(mark)).count();
|
||||
}
|
||||
|
||||
/** 只有未保存具体箱唛的普通仓位库存允许仓库按实点箱数完成。 */
|
||||
public boolean actualQuantityEditable(CargoDiscardItem line) {
|
||||
return "INVENTORY".equals(line.getSourceType()) && line.getInventoryPositionId() != null
|
||||
&& cartonMarks(line).isEmpty() && !Boolean.TRUE.equals(line.getCartonScanRequired());
|
||||
}
|
||||
|
||||
/** 按箱来源执行带原状态条件的变更。 */
|
||||
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
|
||||
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
|
||||
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
|
||||
return repository.changeInboundCarton(customer, cartonId, from, to);
|
||||
}
|
||||
/** 保存本次选箱事实,后续作业不得按数量猜测箱唛。 */
|
||||
private void snapshotMarks(CargoDiscardItem line, List<String> marks) {
|
||||
try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); }
|
||||
catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); }
|
||||
}
|
||||
/** 按真实箱来源完成废弃或释放,转运箱释放后恢复已清点状态。 */
|
||||
/** 仅变更当前申请明细保存的箱子,转运箱释放后恢复已清点状态。 */
|
||||
private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) {
|
||||
for (var link : links.listApplication(customer, application.getId())) {
|
||||
for (var link : repository.selectedCartons(customer, application.getId())) {
|
||||
if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1)
|
||||
throw conflict("预留箱子的库存状态发生变化");
|
||||
}
|
||||
}
|
||||
/** 库存命令使用申请和明细构成的稳定来源与幂等键。 */
|
||||
private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) {
|
||||
String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId();
|
||||
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(),
|
||||
null, cycle, cycle + ":" + action, "货物废弃 " + action);
|
||||
}
|
||||
/** 读取并核对客户指定库存仓位。 */
|
||||
public InventoryPosition position(String customer, Long id) {
|
||||
var position = positions.findTenantPosition(customer, id);
|
||||
var position = repository.position(customer, id);
|
||||
if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户");
|
||||
return position;
|
||||
}
|
||||
/** 只解析申请保存的箱唛,缺失对应关系保留为空。 */
|
||||
public List<String> cartonMarks(CargoDiscardItem line) {
|
||||
if (line.getCartonMarksJson() == null) return List.of();
|
||||
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
|
||||
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
|
||||
}
|
||||
/** 无 SKU 来源保留未指定标记,不猜测商品。 */
|
||||
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
|
||||
/** 空库存桶按零处理。 */
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
/** 返回可恢复的业务冲突。 */
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
|
||||
+3
-2
@@ -28,8 +28,9 @@ public class SmartDispatchController {
|
||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
|
||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
|
||||
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
|
||||
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) {
|
||||
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken)));
|
||||
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken,
|
||||
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
|
||||
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken,page,size,warehouseTab)));
|
||||
}
|
||||
/** 只列出当前拥有者未失效草稿。 */
|
||||
@GetMapping("/drafts")
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+58
-28
@@ -1,35 +1,65 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
|
||||
|
||||
/** BOL读取原调度逐来源数量及真实预约分配,扫码进度和订单商品JOIN均不放大或缩小箱数。 */
|
||||
@Mapper
|
||||
public interface DispatchBolMapper {
|
||||
/** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */
|
||||
default List<com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine> findByDispatchOrder(String customerId, Long dispatchOrderId) {
|
||||
return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList();
|
||||
}
|
||||
record Cargo(Long dispatchItemId,Long orderId,Long planPalletId,String customerId,String destinationType,
|
||||
String deliveryAddress,String postalCode,String containerNo,String palletNo,String fbaWarehouseCode,
|
||||
String destinationWarehouseName,String warehouseNo,String fbaShipmentId,String poReferenceNo,
|
||||
Integer packageCount,Integer itemPackageCount,boolean originalPlan) { }
|
||||
record Allocation(Long dispatchItemId,Long planPalletId,String batchKey,String customerId,String sourceKey,
|
||||
String destinationKey,Integer packageCount,Long appointmentId,Long orderId,String confirmationNo) { }
|
||||
|
||||
@Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," +
|
||||
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
|
||||
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
|
||||
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
|
||||
"MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," +
|
||||
"COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," +
|
||||
"COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," +
|
||||
"GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," +
|
||||
"GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," +
|
||||
"CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " +
|
||||
"WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " +
|
||||
"ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " +
|
||||
"FROM ll_dispatch_item di " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
|
||||
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
|
||||
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
|
||||
"WHERE di.dispatch_order_id=#{dispatchOrderId} " +
|
||||
"GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id")
|
||||
List<DispatchBolData.Line> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
/** 一行对应本车一个真实计划来源行;商品明细只聚合展示字段,原调度数量不取部分已拣数。 */
|
||||
@Select("""
|
||||
SELECT di.id AS dispatchItemId,
|
||||
CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
|
||||
AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN l.order_id ELSE di.order_id END AS orderId,
|
||||
di.plan_pallet_id AS planPalletId,di.customer_id AS customerId,di.destination_type AS destinationType,
|
||||
di.delivery_address AS deliveryAddress,di.postal_code AS postalCode,
|
||||
COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo,
|
||||
MAX(NULLIF(di.pallet_no,'')) AS palletNo,
|
||||
COALESCE(MAX(NULLIF(l.fba_warehouse_code,'')),MAX(NULLIF(l.destination_warehouse_code,'')),
|
||||
MAX(NULLIF(di.fba_warehouse_code,'')),MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode,
|
||||
COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName,
|
||||
COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo,
|
||||
GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId,
|
||||
GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo,
|
||||
CASE WHEN l.id IS NULL THEN MAX(di.package_count)
|
||||
WHEN di.source_stage='RECEIVED' THEN MAX(a.counted_package_count)
|
||||
ELSE MAX(a.planned_package_count) END AS packageCount,
|
||||
MAX(di.package_count) AS itemPackageCount,
|
||||
CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL
|
||||
AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN TRUE ELSE FALSE END AS originalPlan
|
||||
FROM ll_dispatch_item di
|
||||
LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id
|
||||
LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id
|
||||
AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER'
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id
|
||||
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id
|
||||
LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id
|
||||
WHERE di.dispatch_order_id=#{dispatchOrderId}
|
||||
GROUP BY di.id,di.order_id,di.plan_pallet_id,di.customer_id,di.destination_type,di.delivery_address,
|
||||
di.postal_code,di.source_stage,di.return_receipt_id,di.source_business_type,l.id,l.order_id,a.id
|
||||
ORDER BY di.id,l.id,a.id
|
||||
""")
|
||||
List<Cargo> findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId);
|
||||
|
||||
/** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */
|
||||
@Select("""
|
||||
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,
|
||||
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
|
||||
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo
|
||||
FROM ll_dispatch_appointment_allocation a
|
||||
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
|
||||
LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id
|
||||
AND i.source_key=a.source_key AND CONCAT('FBA:',i.fba_warehouse_code)=a.destination_key
|
||||
WHERE a.dispatch_id=#{dispatchOrderId} AND a.batch_key LIKE 'FBA_APPOINTMENT:%'
|
||||
ORDER BY a.dispatch_item_id,a.customer_id,a.source_key,a.destination_key,a.batch_key
|
||||
""")
|
||||
List<Allocation> findAppointmentAllocations(@Param("dispatchOrderId") long dispatchOrderId);
|
||||
}
|
||||
|
||||
+37
-18
@@ -9,6 +9,21 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchCandidateMapper {
|
||||
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||
String DEPARTED_PLAN =
|
||||
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||
"ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" +
|
||||
"SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||
"ON dl.id=da.line_id LEFT JOIN ll_order departure_order " +
|
||||
"ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " +
|
||||
"(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" +
|
||||
"CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" +
|
||||
"SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " +
|
||||
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
|
||||
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
|
||||
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
|
||||
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
||||
String CONFIRMED_APPOINTMENT_AT =
|
||||
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
||||
@@ -41,7 +56,7 @@ public interface DispatchCandidateMapper {
|
||||
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
|
||||
|
||||
String AVAILABLE_PLAN_BODY =
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,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(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," +
|
||||
@@ -53,16 +68,16 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||
"CAST(CASE WHEN 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 " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
"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," +
|
||||
"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 " +
|
||||
"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," +
|
||||
@@ -70,11 +85,11 @@ public interface DispatchCandidateMapper {
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||
"CASE WHEN 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) " +
|
||||
"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," +
|
||||
"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 ',') " +
|
||||
"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 " +
|
||||
@@ -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='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<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 " +
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (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)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
"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);
|
||||
|
||||
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(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," +
|
||||
@@ -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," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
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 " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
"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," +
|
||||
"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 " +
|
||||
"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," +
|
||||
@@ -178,11 +194,11 @@ public interface DispatchCandidateMapper {
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||
"CASE WHEN 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) " +
|
||||
"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," +
|
||||
"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 ',') " +
|
||||
"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 " +
|
||||
@@ -216,7 +232,8 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (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)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
"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='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||
"AND pos.hold_packages > 0 " +
|
||||
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
|
||||
"AND pos.hold_packages > 0 AND pos.pallet_id > 0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||
"ON existing_dispatch.id=di.dispatch_order_id " +
|
||||
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
|
||||
@@ -283,8 +301,9 @@ public interface DispatchCandidateMapper {
|
||||
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
|
||||
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
|
||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.weight) THEN SUM(x.weight) END FROM (SELECT DISTINCT c2.id,c2.weight FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.volume) THEN SUM(x.volume) END FROM (SELECT DISTINCT c2.id,(c2.length*c2.width*c2.height)/1000000 AS volume FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
|
||||
// 从唯一实箱汇总并用EXISTS核对申请,避免一箱多明细重复计量和跨层相关派生表。
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.weight) THEN SUM(c2.weight) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
|
||||
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.length*c2.width*c2.height) THEN SUM(c2.length*c2.width*c2.height/1000000) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
|
||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
|
||||
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
|
||||
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 四种来源共用数据库分页;页外来源不进入测量、完整组和预约的逐组装配。 */
|
||||
@Mapper
|
||||
public interface DispatchCandidatePageMapper {
|
||||
/** 统计相同筛选下的来源行,不把当前页条数当全仓总数。 */
|
||||
@SelectProvider(type=Sql.class,method="count")
|
||||
long count(@Param("query")CandidateQuery query);
|
||||
/** 稳定排序后在数据库限页,完整组可包含页外成员。 */
|
||||
@SelectProvider(type=Sql.class,method="page")
|
||||
List<DispatchPalletCandidate> page(@Param("query")CandidateQuery query,@Param("offset")long offset);
|
||||
|
||||
/** 复用既有资格SQL,只统一各来源投影的列序,不复制资格或客户隔离规则。 */
|
||||
final class Sql {
|
||||
private static final String FIELDS="planId,planPalletId,warehousePalletId,orderId,customerId,customerName,planNo,orderType,volumeWeightKg,grossWeightKg,containerNo,appointmentAt,expectedLoadingAt,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,packageCount,volumeCbm,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptId,returnReceiptNo,returnCartonSource";
|
||||
private static final Set<String> PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource");
|
||||
private static final Set<String> RETURN_FIELDS=Set.of(("returnReceiptId,returnReceiptNo,orderId,customerId,customerName,orderType,orderNo,orderRefs,inboundCode,warehousePalletId,palletNo,overseasWarehouseId,overseasWarehouseName,overseasWarehouseCode,destinationType,destinationWarehouseCode,fbaWarehouseCode,destinationWarehouseName,deliveryAddress,postalCode,contactName,contactPhone,sourceStage,packageCount").split(","));
|
||||
private static final Set<String> NATIVE_FIELDS=Set.of(("customerId,orderId,overseasWarehouseId,overseasWarehouseName,orderType,orderNo,orderRefs,inboundCode,palletNo,packageCount,sourceStage,returnCartonSource,relabelCartonMarks,fbaWarehouseCode,grossWeightKg,volumeCbm,destinationWarehouseCode").split(","));
|
||||
private static final String BINDINGS="<bind name='customerId' value='null'/><bind name='relabelNo' value='null'/>"
|
||||
+"<bind name='planIds' value='query.planIds'/><bind name='orderNo' value='query.orderNo'/>"
|
||||
+"<bind name='fbaWarehouseCode' value='query.destinationWarehouseCode'/><bind name='fba' value='query.destinationWarehouseCode'/>"
|
||||
+"<bind name='sourceStage' value='query.sourceStage'/><bind name='draftToken' value='query.draftToken'/>";
|
||||
/** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */
|
||||
private static String project(String body,Set<String> fields,boolean count) {
|
||||
return "SELECT "+Arrays.stream((count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS).split(",")).map(field->fields.contains(field)?"s."+field:
|
||||
("returnCartonSource".equals(field)?"0":"NULL")+" AS "+field).collect(Collectors.joining(","))
|
||||
+" FROM ("+body+") s";
|
||||
}
|
||||
/** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */
|
||||
private static String rows(boolean count) {
|
||||
var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING);
|
||||
var fields=count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS;
|
||||
return "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields,count)
|
||||
+"</when><otherwise>SELECT "+Arrays.stream(fields.split(",")).map(f->"NULL AS "+f).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
|
||||
+"<if test='planIds == null or planIds.isEmpty()'>"
|
||||
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS,count)+"</if>"
|
||||
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED' or sourceStage == 'RELABEL'\"> UNION ALL "
|
||||
+project(DispatchCandidateMapper.RELABEL_BODY,planFields,count)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS,count)+"</if></if>";
|
||||
}
|
||||
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
|
||||
private static String filtered(boolean count) {
|
||||
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
|
||||
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
|
||||
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
|
||||
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
|
||||
}
|
||||
/** 同一事务内统计分页全集。 */
|
||||
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered(true)+"</script>";}
|
||||
/** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */
|
||||
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered(false)
|
||||
+" ORDER BY customerId,orderNo,palletNo,sourceStage,planPalletId,returnReceiptId,orderRefs,returnCartonSource"
|
||||
+" LIMIT #{query.size} OFFSET #{offset}</script>";}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
|
||||
@Mapper
|
||||
public interface DispatchNumberSequenceMapper {
|
||||
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
|
||||
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
|
||||
int allocate(@Param("date") LocalDate date);
|
||||
|
||||
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
|
||||
@Select("SELECT LAST_INSERT_ID()")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
Long allocatedValue();
|
||||
}
|
||||
+23
@@ -11,6 +11,8 @@ public interface DispatchSourceMapper {
|
||||
long allocatedPackages, long pickedPackages, long version) { }
|
||||
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
|
||||
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
|
||||
record PalletReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages,String inboundCode) { }
|
||||
record PalletReturnedReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages) { }
|
||||
record Claim(Long id, Long dispatchId, String draftToken) { }
|
||||
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
|
||||
record NativeApplication(String customerId,String relabelNo) { }
|
||||
@@ -112,6 +114,27 @@ public interface DispatchSourceMapper {
|
||||
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
|
||||
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
|
||||
|
||||
/** 同客户整页实托一次读齐库存版本,仍保留原仓身份供服务校验。 */
|
||||
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,"
|
||||
+ "available_packages AS availablePackages,hold_packages AS holdPackages,allocated_packages AS allocatedPackages,"
|
||||
+ "picked_packages AS pickedPackages,version FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id IN "
|
||||
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<Stock> stocks(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||
|
||||
/** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */
|
||||
@Select("<script>SELECT warehouse_pallet_id AS palletId,id,order_id AS orderId,inventory_position_id AS inventoryPositionId,"
|
||||
+ "received_packages AS packages,inbound_code AS inboundCode FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id IN "
|
||||
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
|
||||
List<PalletReceipt> palletReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||
|
||||
/** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */
|
||||
@Select("<script>SELECT p.pallet_id AS palletId,r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,"
|
||||
+ "r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
|
||||
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id IN "
|
||||
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY r.id</script>")
|
||||
List<PalletReturnedReceipt> palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||
|
||||
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
|
||||
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
|
||||
|
||||
+9
-5
@@ -3,13 +3,17 @@ import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */
|
||||
public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName,
|
||||
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines,Map<String,String> appointmentNumbers) {
|
||||
public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo,
|
||||
String vehiclePlate,LocalDateTime estimatedArrivalAt,List<Line> bolLines) {
|
||||
public record Line(Long dispatchItemId,Long orderId,String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo,
|
||||
String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId,
|
||||
String poReferenceNo,int packageCount) {
|
||||
String poReferenceNo,int packageCount,String appointmentNo) {
|
||||
/** 外部确认号只去首尾空白,未取得真实约号时保留空。 */
|
||||
public Line { appointmentNo=appointmentNo==null || appointmentNo.isBlank()?null:appointmentNo.strip(); }
|
||||
/** 目的地身份不使用客户作为分组条件。 */
|
||||
public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);}
|
||||
/** 打印展示使用业务名称或真实地址。 */
|
||||
public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;}
|
||||
public String groupKey(){return customerId+"\u0000"+destinationKey();}
|
||||
/** 同目的地同约号跨客户合并,无号也可组成同目的地文档。 */
|
||||
public String groupKey(){return destinationKey()+"\u0000"+Objects.requireNonNullElse(appointmentNo,"");}
|
||||
}
|
||||
public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());}
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation;
|
||||
|
||||
@Service
|
||||
public class DispatchPickOrderAttributionService {
|
||||
@@ -39,7 +39,7 @@ public class DispatchPickOrderAttributionService {
|
||||
|
||||
/** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List<DispatchItemSourceService.Allocation> allocations) {
|
||||
public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List<DispatchSourceAllocation> allocations) {
|
||||
long packages=0,quantity=0;var seen=new java.util.HashSet<Long>();
|
||||
for(var allocation:allocations) {
|
||||
var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId());
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */
|
||||
public interface DispatchDetailReader {
|
||||
/** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */
|
||||
DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo);
|
||||
}
|
||||
+5
-2
@@ -12,7 +12,8 @@ public final class DispatchPlanningModels {
|
||||
private DispatchPlanningModels() { }
|
||||
|
||||
public record CandidateQuery(String warehouseId, List<Long> planIds, String orderNo,
|
||||
String destinationWarehouseCode, String sourceStage, String draftToken) { }
|
||||
String destinationWarehouseCode, String sourceStage, String draftToken,
|
||||
int page, int size, String warehouseTab) { }
|
||||
|
||||
/** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */
|
||||
public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion,
|
||||
@@ -36,7 +37,9 @@ public final class DispatchPlanningModels {
|
||||
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
|
||||
long packageCount) { }
|
||||
|
||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
|
||||
/** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */
|
||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete,
|
||||
long total, int page, int size, long pages) { }
|
||||
|
||||
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
|
||||
List<String> sourceKeys) { }
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
/** 一次真实拣货消费的冻结来源额度;件数未知时保留空,不接受请求伪造来源身份。 */
|
||||
public record DispatchSourceAllocation(Long dispatchItemSourceId, long packages, Long productQuantity) { }
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** BOL专用只读事实入口,应用服务不直接持有Mapper。 */
|
||||
@Repository
|
||||
public class DispatchBolRepository {
|
||||
private final DispatchBolMapper mapper;
|
||||
/** 装配同一次事务使用的来源及分配读取。 */
|
||||
public DispatchBolRepository(DispatchBolMapper mapper){this.mapper=mapper;}
|
||||
/** 返回完整明细身份、原调度箱数及逐来源打印资料。 */
|
||||
public List<DispatchBolMapper.Cargo> cargo(long dispatchId){return mapper.findWholeDispatch(dispatchId);}
|
||||
/** 仅正式FBA预约分配有外部预约号;客户仓内部DISPATCH批次不伪造约号。 */
|
||||
public List<DispatchBolMapper.Allocation> allocations(long dispatchId){return mapper.findAppointmentAllocations(dispatchId);}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+25
@@ -11,6 +11,13 @@ import java.util.List;
|
||||
/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */
|
||||
@Repository
|
||||
public class DispatchSourceRepository {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper pages;
|
||||
/** 筛选全集的数据库行数,与分页使用同一只读事务。 */
|
||||
public long candidateCount(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){return pages.count(query);}
|
||||
/** 只返回当前页来源,禁止先全量装配再截取。 */
|
||||
public List<DispatchPalletCandidate> candidatePage(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){
|
||||
return pages.page(query,Math.multiplyExact((long)query.page()-1,query.size()));
|
||||
}
|
||||
private final DispatchCandidateMapper plans;
|
||||
private final ReturnDispatchCandidateMapper returns;
|
||||
private final DispatchSourceMapper source;
|
||||
@@ -75,6 +82,24 @@ public class DispatchSourceRepository {
|
||||
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
|
||||
/** 同一实托的退回实收不得从来源证明丢失。 */
|
||||
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
|
||||
/** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */
|
||||
public record SourceFacts(List<DispatchSourceMapper.Stock> stock,List<DispatchSourceMapper.Receipt> receipts,
|
||||
List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts) { }
|
||||
/** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */
|
||||
public java.util.Map<Long,SourceFacts> sourceFacts(String customer,List<Long> pallets) {
|
||||
if(pallets.isEmpty())return java.util.Map.of();
|
||||
var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId));
|
||||
var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping(
|
||||
row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList())));
|
||||
var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping(
|
||||
row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList())));
|
||||
var result=new java.util.LinkedHashMap<Long,SourceFacts>();
|
||||
for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())),
|
||||
List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of()))));
|
||||
return java.util.Collections.unmodifiableMap(result);
|
||||
}
|
||||
/** 释放已到期草稿键。 */
|
||||
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
|
||||
/** 锁定当前活动资源占用。 */
|
||||
|
||||
+35
-6
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -31,6 +32,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
||||
@Service
|
||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
||||
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
||||
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
|
||||
@@ -47,6 +50,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
private final ObjectMapper json;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
|
||||
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
|
||||
|
||||
/** 领域编排只访问Repository和提供者应用入口。 */
|
||||
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
|
||||
@@ -144,11 +148,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
requirePlatform(actor);
|
||||
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
|
||||
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
|
||||
if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法");
|
||||
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
|
||||
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
|
||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()),query.page(),query.size(),query.warehouseTab());
|
||||
long started=System.nanoTime();
|
||||
var page=sourceRules.candidatePage(actor.operatorId(),normalized);
|
||||
var rows=page.rows();
|
||||
long candidatesRead=System.nanoTime();
|
||||
var groups=sourceRules.selectionGroups(rows);
|
||||
long groupsRead=System.nanoTime();
|
||||
var batchFacts=appointments.candidates(rows);
|
||||
long batchesRead=System.nanoTime();
|
||||
var result=new ArrayList<DispatchPlanningModels.Candidate>();
|
||||
for(var row:rows) {
|
||||
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
|
||||
@@ -178,7 +188,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
|
||||
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
|
||||
}
|
||||
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
|
||||
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),
|
||||
normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(),
|
||||
(page.total()+normalized.size()-1)/normalized.size());
|
||||
}
|
||||
|
||||
/** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */
|
||||
@@ -267,7 +285,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var replay = commands.replay(actor,null,identity);
|
||||
if (replay.isPresent()) return replay.get();
|
||||
long id=IdWorker.getId();
|
||||
String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
|
||||
String number=numbers.next();
|
||||
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
|
||||
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
|
||||
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
|
||||
@@ -369,7 +387,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request);
|
||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||
requireEditing(row,request.recordVersion());
|
||||
if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
|
||||
verifyReviewResources(row);
|
||||
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
|
||||
long round=row.getReviewRound()+1;String basis=basis(row);
|
||||
@@ -522,6 +540,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());
|
||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
|
||||
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
|
||||
// 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。
|
||||
// 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。
|
||||
ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(),
|
||||
context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at);
|
||||
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
|
||||
}
|
||||
|
||||
@@ -739,6 +761,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物");
|
||||
}
|
||||
|
||||
/** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */
|
||||
private static boolean canSubmitReview(String reviewState) {
|
||||
return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState)
|
||||
|| ReviewState.LOADING_REJECTED.name().equals(reviewState);
|
||||
}
|
||||
|
||||
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
|
||||
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
@@ -746,7 +774,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
for(var action:Action.values()) {
|
||||
boolean state=switch(action) {
|
||||
case CREATE -> actor.mode()==ActorMode.PLATFORM;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
||||
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
|
||||
case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING;
|
||||
case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING;
|
||||
case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0;
|
||||
|
||||
+3
-1
@@ -32,6 +32,7 @@ public class DispatchBolPrintService {
|
||||
@Resource private DomesticWarehouseDirectoryClient warehouses;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
/** 按本车一致快照组装全部BOL,一次调用打印服务并保持一个PDF下载。 */
|
||||
public GeneratedPdf generate(String dispatchNo, String requestId) {
|
||||
DispatchBolData detail = dispatches.read(dispatchNo);
|
||||
if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
|
||||
@@ -54,6 +55,7 @@ public class DispatchBolPrintService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将同目的地同预约号的跨客户货物组装为独立分页BOL,无预约号时ISA保持空白。 */
|
||||
private List<PrintPdfClient.Document> documents(DispatchBolData detail) {
|
||||
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
|
||||
Map<String, List<DispatchBolData.Line>> groups = new LinkedHashMap<>();
|
||||
@@ -72,7 +74,7 @@ public class DispatchBolPrintService {
|
||||
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()),
|
||||
text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
|
||||
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()),
|
||||
text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
|
||||
lines.get(0).appointmentNo() == null ? "" : lines.get(0).appointmentNo().strip(), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
|
||||
text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
|
||||
total, page + 1, pages, rows));
|
||||
offset = end;
|
||||
|
||||
+71
-10
@@ -1,31 +1,92 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchBolData;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */
|
||||
|
||||
/** 同一只读事务内核实本车逐来源数量,再按真实预约份额拆行;事务结束后才调用远程打印。 */
|
||||
@Service
|
||||
public class DispatchBolReadService {
|
||||
private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json;
|
||||
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;}
|
||||
private final DispatchAggregateRepository roots;private final DispatchBolRepository facts;private final ObjectMapper json;
|
||||
private record Source(Long itemId,String customerId,Long orderId,String destinationKey) { }
|
||||
private record AllocationKey(Long itemId,String batchKey,String customerId,String sourceKey,String destinationKey) { }
|
||||
/** 打印读取只经Repository,不写预约、调度或库存事实。 */
|
||||
public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolRepository facts,ObjectMapper json){this.roots=roots;this.facts=facts;this.json=json;}
|
||||
|
||||
/** 保留原调度总量,未约部分以空约号打印,不按部分拣货进度缩量。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchBolData read(String number) {
|
||||
var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在");
|
||||
Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);}
|
||||
var cargo=lines.findWholeDispatch(row.getId());
|
||||
if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实");
|
||||
var confirmations=new LinkedHashMap<String,LinkedHashSet<String>>();
|
||||
for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo());
|
||||
var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue())));
|
||||
var cargo=new LinkedHashMap<Source,DispatchBolData.Line>();var totals=new HashMap<Long,Integer>();var pallets=new HashMap<Long,Long>();
|
||||
for(var raw:facts.cargo(row.getId())) {
|
||||
int packages=nonnegative(raw.packageCount());var line=line(raw,null,packages);
|
||||
if(raw.dispatchItemId()==null || blank(raw.customerId()) || line.destinationKey()==null
|
||||
|| raw.originalPlan() && raw.orderId()==null)throw invalid();
|
||||
int total=positive(raw.itemPackageCount());var prior=totals.putIfAbsent(raw.dispatchItemId(),total);
|
||||
if(prior!=null && prior!=total)throw invalid();
|
||||
if(pallets.containsKey(raw.dispatchItemId()) && !Objects.equals(pallets.get(raw.dispatchItemId()),raw.planPalletId()))throw invalid();
|
||||
pallets.put(raw.dispatchItemId(),raw.planPalletId());
|
||||
if(packages==0)continue;
|
||||
var key=new Source(raw.dispatchItemId(),raw.customerId(),raw.orderId(),line.destinationKey());
|
||||
cargo.merge(key,line,DispatchBolReadService::merge);
|
||||
}
|
||||
var sourceTotals=new HashMap<Long,Integer>();
|
||||
cargo.forEach((key,line)->sourceTotals.merge(key.itemId(),line.packageCount(),Math::addExact));
|
||||
if(!sourceTotals.equals(totals))throw invalid();
|
||||
var remaining=new LinkedHashMap<Source,Integer>();cargo.forEach((key,line)->remaining.put(key,line.packageCount()));
|
||||
var seen=new HashSet<AllocationKey>();var result=new ArrayList<DispatchBolData.Line>();
|
||||
for(var allocation:facts.allocations(row.getId())) {
|
||||
if(allocation.appointmentId()==null || allocation.orderId()==null || blank(allocation.sourceKey())
|
||||
|| !Objects.equals(pallets.get(allocation.dispatchItemId()),allocation.planPalletId()))throw invalid();
|
||||
var key=new Source(allocation.dispatchItemId(),allocation.customerId(),allocation.orderId(),allocation.destinationKey());
|
||||
var source=cargo.get(key);int count=positive(allocation.packageCount());
|
||||
if(source==null || !seen.add(new AllocationKey(allocation.dispatchItemId(),allocation.batchKey(),allocation.customerId(),allocation.sourceKey(),allocation.destinationKey()))
|
||||
|| count>remaining.get(key))throw invalid();
|
||||
remaining.put(key,remaining.get(key)-count);result.add(copy(source,count,allocation.confirmationNo()));
|
||||
}
|
||||
// 每个来源只消费实际分配份额,余额有明确实物依据,保持空ISA而非重复整托。
|
||||
remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));});
|
||||
var printedTotals=new HashMap<Long,Integer>();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact));
|
||||
if(!printedTotals.equals(totals))throw invalid();
|
||||
return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(),
|
||||
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers));
|
||||
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result));
|
||||
}
|
||||
/** 将数据库原始来源行转换为带真实身份的内部打印行。 */
|
||||
private static DispatchBolData.Line line(DispatchBolMapper.Cargo c,String appointment,int packages) {
|
||||
return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(),
|
||||
c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment);
|
||||
}
|
||||
/** 拆份只改变份额和预约号,真实客户、源订单和排车明细身份不变。 */
|
||||
private static DispatchBolData.Line copy(DispatchBolData.Line c,int packages,String appointment) {
|
||||
return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(),
|
||||
c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment);
|
||||
}
|
||||
/** 同订单多来源行先守恒合并,再让每条预约分配消费一次;展示字符串只去重合并。 */
|
||||
private static DispatchBolData.Line merge(DispatchBolData.Line a,DispatchBolData.Line b) {
|
||||
return new DispatchBolData.Line(a.dispatchItemId(),a.orderId(),a.customerId(),a.destinationType(),a.deliveryAddress(),a.postalCode(),join(a.containerNo(),b.containerNo()),a.palletNo(),
|
||||
a.fbaWarehouseCode(),a.destinationWarehouseName(),join(a.warehouseNo(),b.warehouseNo()),join(a.fbaShipmentId(),b.fbaShipmentId()),join(a.poReferenceNo(),b.poReferenceNo()),Math.addExact(a.packageCount(),b.packageCount()),null);
|
||||
}
|
||||
/** 仅打印展示字段去重,不用逗号字符串表示预约身份。 */
|
||||
private static String join(String a,String b) {
|
||||
var values=new LinkedHashSet<String>();for(var text:Arrays.asList(a,b))if(text!=null)for(var part:text.split(","))if(!part.isBlank())values.add(part.strip());
|
||||
return values.isEmpty()?null:String.join(", ",values);
|
||||
}
|
||||
/** 数量未知或非正不得伪造打印份额。 */
|
||||
private static int positive(Integer value){if(value==null || value<=0)throw invalid();return value;}
|
||||
/** 已核实为零的源行没有待打印货物,但不以零替代未知数量。 */
|
||||
private static int nonnegative(Integer value){if(value==null || value<0)throw invalid();return value;}
|
||||
/** 来源身份必须来自真实持久事实。 */
|
||||
private static boolean blank(String value){return value==null || value.isBlank();}
|
||||
/** 不完整、跨客户或超额分配不能生成看似守恒的BOL。 */
|
||||
private static BizException invalid(){return new BizException(ResultCode.VALIDATION_FAILED,"BOL货物来源、预约归属或分配箱数尚未核实");}
|
||||
}
|
||||
|
||||
+5
-7
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.*;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation;
|
||||
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,9 +24,6 @@ public class DispatchItemSourceService {
|
||||
/** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */
|
||||
public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;}
|
||||
|
||||
/** packages是本次实际消费量,件数未知保留空;不是请求可伪造的来源身份。 */
|
||||
public record Allocation(Long dispatchItemSourceId,long packages,Long productQuantity) { }
|
||||
|
||||
/** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public void capture(DispatchAggregate root,List<DispatchItem> all,List<DispatchItem> added,LocalDateTime at) {
|
||||
@@ -108,7 +106,7 @@ public class DispatchItemSourceService {
|
||||
|
||||
/** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */
|
||||
@Transactional(propagation=Propagation.MANDATORY)
|
||||
public List<Allocation> allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List<DispatchSourceMapper.Stock> actualStock) {
|
||||
public List<DispatchSourceAllocation> allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List<DispatchSourceMapper.Stock> actualStock) {
|
||||
var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p));
|
||||
var expected=new HashMap<Long,Long>();
|
||||
for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) {
|
||||
@@ -127,14 +125,14 @@ public class DispatchItemSourceService {
|
||||
for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);}
|
||||
if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数");
|
||||
if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数");
|
||||
var result=new ArrayList<Allocation>();long knownQuantity=0;
|
||||
var result=new ArrayList<DispatchSourceAllocation>();long knownQuantity=0;
|
||||
for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);}
|
||||
if(rows.size()>1) {
|
||||
if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数");
|
||||
for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new Allocation(row.getId(),remaining.get(row.getId()),quantity));}
|
||||
for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new DispatchSourceAllocation(row.getId(),remaining.get(row.getId()),quantity));}
|
||||
} else {
|
||||
var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致");
|
||||
result.add(new Allocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity));
|
||||
result.add(new DispatchSourceAllocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -16,6 +16,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
@Service
|
||||
public class DispatchPdaQueryService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
|
||||
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
|
||||
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
|
||||
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
|
||||
@@ -28,7 +30,7 @@ public class DispatchPdaQueryService {
|
||||
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
|
||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
|
||||
}
|
||||
/** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */
|
||||
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
|
||||
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
|
||||
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
|
||||
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
|
||||
@@ -59,6 +61,8 @@ public class DispatchPdaQueryService {
|
||||
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
|
||||
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
|
||||
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
||||
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
|
||||
sourcePositions=receivedPlanPositions(actor,item,items);
|
||||
if(normalRelabel(item)) {
|
||||
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
|
||||
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
|
||||
@@ -89,6 +93,39 @@ public class DispatchPdaQueryService {
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
|
||||
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||
if(rows.size()!=1)return List.of();
|
||||
var actual=rows.get(0);
|
||||
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|
||||
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|
||||
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|
||||
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
|
||||
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
|
||||
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
|
||||
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
|
||||
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
|
||||
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
|
||||
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
|
||||
for(var member:group.sources()) {
|
||||
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
|
||||
|| n(selected.getPackageCount())!=member.packages())return List.of();
|
||||
}
|
||||
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|
||||
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|
||||
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
|
||||
return stock.stream().filter(p->p.availablePackages()>0)
|
||||
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
|
||||
}
|
||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||
/** 缺失计数不作为业务标识使用。 */
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ public class DispatchPickingService {
|
||||
boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage());
|
||||
if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位");
|
||||
long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType());
|
||||
List<DispatchItemSourceService.Allocation> relabelAllocations=List.of();
|
||||
List<DispatchSourceAllocation> relabelAllocations=List.of();
|
||||
if(nativeReturn) {
|
||||
attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId());
|
||||
returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId();
|
||||
|
||||
+80
-29
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -34,16 +35,64 @@ public class DispatchService {
|
||||
|
||||
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
|
||||
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
|
||||
var reads=new CandidateReadScope(rows);
|
||||
var result=new LinkedHashMap<String,SelectionGroup>();
|
||||
for(var row:rows) {
|
||||
String key=wholeSourceKey(selection(row,null));
|
||||
if(result.containsKey(key))continue;
|
||||
var group=sourceGroup(row,false);
|
||||
var group=sourceGroup(row,false,reads);
|
||||
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
/** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */
|
||||
private final class CandidateReadScope {
|
||||
private final Map<String,Set<Long>> loaded=new HashMap<>();
|
||||
private final Map<String,Map<Long,List<DispatchPalletCandidate>>> available=new HashMap<>();
|
||||
private final Map<String,Map<Long,SourceFacts>> sourceFacts=new HashMap<>();
|
||||
|
||||
/** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */
|
||||
private CandidateReadScope(List<DispatchPalletCandidate> rows) {
|
||||
var pallets=new TreeMap<String,Set<Long>>();
|
||||
for(var row:rows)if(row.warehousePalletId()!=null)
|
||||
pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId());
|
||||
pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids)));
|
||||
loadFacts(rows);
|
||||
}
|
||||
|
||||
/** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */
|
||||
private List<DispatchPalletCandidate> read(String warehouse,List<Long> pallets) {
|
||||
var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>());
|
||||
var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>());
|
||||
var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList();
|
||||
if(!missing.isEmpty()) {
|
||||
var rows=wholeSources.physicalAvailable(warehouse,missing);
|
||||
for(var row:rows)
|
||||
byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row);
|
||||
loadFacts(rows);
|
||||
known.addAll(missing);
|
||||
}
|
||||
return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList();
|
||||
}
|
||||
|
||||
/** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */
|
||||
private void loadFacts(Collection<DispatchPalletCandidate> rows) {
|
||||
var missing=new TreeMap<String,Set<Long>>();
|
||||
for(var row:rows)if(row.warehousePalletId()!=null
|
||||
&& !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId()))
|
||||
missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId());
|
||||
missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>())
|
||||
.putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets))));
|
||||
}
|
||||
|
||||
/** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */
|
||||
private SourceFacts facts(DispatchPalletCandidate row) {
|
||||
return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
|
||||
Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失");
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
|
||||
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
|
||||
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
|
||||
@@ -78,6 +127,11 @@ public class DispatchService {
|
||||
|
||||
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
|
||||
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
|
||||
return sourceGroup(seed,locked,null);
|
||||
}
|
||||
|
||||
/** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */
|
||||
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) {
|
||||
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
|
||||
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
|
||||
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
|
||||
@@ -90,7 +144,9 @@ public class DispatchService {
|
||||
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
|
||||
reasons.addAll(facts.blockReasons());
|
||||
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
|
||||
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
|
||||
.filter(id->id!=null && id>0).forEach(pallets::add);
|
||||
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
|
||||
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
|
||||
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
|
||||
@@ -109,7 +165,8 @@ public class DispatchService {
|
||||
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
|
||||
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
|
||||
if(!pallets.isEmpty()) {
|
||||
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
|
||||
reads.read(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
|
||||
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
|
||||
for(var entry:buckets.entrySet()) {
|
||||
@@ -130,7 +187,8 @@ public class DispatchService {
|
||||
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
|
||||
}
|
||||
}
|
||||
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
|
||||
if(reads!=null)reads.loadFacts(rows.values());
|
||||
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads));
|
||||
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
|
||||
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
|
||||
var sources=new ArrayList<SelectionGroupSource>();
|
||||
@@ -154,32 +212,22 @@ public class DispatchService {
|
||||
public record WholeSource(DispatchPalletCandidate candidate,
|
||||
com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { }
|
||||
|
||||
/** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */
|
||||
public List<DispatchPalletCandidate> wholeCandidates(String operator,
|
||||
com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) {
|
||||
/** 总数与当前页同事务读取;页外候选不参与逐组装配。 */
|
||||
public record CandidatePage(List<DispatchPalletCandidate> rows,long total) { }
|
||||
|
||||
/** 草稿所有权先验证,数据库分页后才读取测量和原生退货物理身份。 */
|
||||
public CandidatePage candidatePage(String operator,CandidateQuery query) {
|
||||
validateSourceStage(query.sourceStage());
|
||||
if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream()
|
||||
.noneMatch(d -> query.draftToken().equals(d.getDraftToken())))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源");
|
||||
var result=new java.util.ArrayList<DispatchPalletCandidate>();
|
||||
if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query));
|
||||
if(query.planIds()==null || query.planIds().isEmpty()) {
|
||||
if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query));
|
||||
if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) {
|
||||
var rows=wholeSources.availableRelabel(query);
|
||||
if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
|
||||
result.addAll(measuredRelabelCandidates(rows));
|
||||
result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo()));
|
||||
}
|
||||
}
|
||||
if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
|
||||
return result.stream().filter(row -> query.destinationWarehouseCode()==null
|
||||
|| query.destinationWarehouseCode().equals(row.fbaWarehouseCode()))
|
||||
.sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),""))
|
||||
.thenComparing(row -> Objects.toString(row.orderNo(),""))
|
||||
.thenComparing(row -> Objects.toString(row.palletNo(),""))).toList();
|
||||
long total=wholeSources.candidateCount(query);
|
||||
var rows=wholeSources.candidatePage(query);
|
||||
measuredRelabelCandidates(rows.stream().filter(row->"RELABEL".equals(row.sourceStage()) && !row.returnCartonSource()).toList());
|
||||
var nativeRows=rows.stream().filter(DispatchPalletCandidate::returnCartonSource).toList();
|
||||
if(!nativeRows.isEmpty())returnRelabelStock.resolvePage(nativeRows);
|
||||
return new CandidatePage(List.copyOf(rows),total);
|
||||
}
|
||||
|
||||
/** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public List<WholeSource> lockWholeSources(String warehouseId,
|
||||
@@ -397,7 +445,7 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
|
||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
|
||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
|
||||
var facts = new java.util.TreeMap<String,Object>();
|
||||
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
|
||||
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
|
||||
@@ -409,12 +457,15 @@ public class DispatchService {
|
||||
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
|
||||
facts.put("cartonMarks",row.relabelCartonMarks());
|
||||
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
|
||||
var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
|
||||
var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
|
||||
reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false),
|
||||
wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
||||
var stock=basis.stock();
|
||||
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
|
||||
facts.put("stock",stock);
|
||||
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
|
||||
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
||||
facts.put("receipts",basis.receipts());
|
||||
facts.put("returnedReceipts",basis.returnedReceipts());
|
||||
return wholeHashes.source(facts);
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -27,11 +27,9 @@ public class ReturnRelabelDispatchStock {
|
||||
return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo));
|
||||
}
|
||||
|
||||
/** 整车候选只解析已经按仓限定且有界的原生退货记录。 */
|
||||
public List<DispatchPalletCandidate> wholeCandidates(String warehouse,String orderNo) {
|
||||
var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null);
|
||||
if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围");
|
||||
return resolve(rows);
|
||||
/** 分页后仅核实本页原生退货实托;身份失效明确拒绝,不能静默缩页并返回错误总数。 */
|
||||
public void resolvePage(List<DispatchPalletCandidate> rows) {
|
||||
if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查");
|
||||
}
|
||||
|
||||
/** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */
|
||||
|
||||
@@ -75,7 +75,9 @@ public record LastlegOrderSummary(
|
||||
LocalDateTime updatedAt,
|
||||
@Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序")
|
||||
List<RelabelSubOrder> relabelApplications,
|
||||
BusinessSource businessSource
|
||||
BusinessSource businessSource,
|
||||
@Schema(description = "仓储转运专属预报、历史实收及当前箱托数量;其他业务为空")
|
||||
com.baiyue.lastleg.transfer.dto.TransferQuantityView warehouseTransferQuantity
|
||||
) {
|
||||
@Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立")
|
||||
public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { }
|
||||
|
||||
+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; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user