Compare commits

..
Author SHA1 Message Date
ouyangxq 6f31fa2131 fix(appointments): open canonical dispatch details from calendar 2026-10-20 16:36:22 +08:00
120 changed files with 508 additions and 6882 deletions
@@ -1,10 +0,0 @@
import subprocess, shutil, xml.etree.ElementTree as ET
from pathlib import Path
root=Path.cwd()
report=root/'lastleg-service/target/surefire-reports/TEST-com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.xml'
properties=ET.parse(report).getroot().find('properties')
classpath=next(item.attrib['value'] for item in properties if item.attrib['name']=='java.class.path')
sources=[str(root/'lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip'/name) for name in ['FleetTripCalendarServiceTest.java','FleetTripCalendarMapperTest.java']]
subprocess.run(['javac','-encoding','UTF-8','--release','17','-cp',classpath,'-d',str(root/'lastleg-service/target/test-classes'),*sources],check=True)
result=subprocess.run([shutil.which('mvn'),' -T'.strip(),'1','-DforkCount=1','-DreuseForks=true','-pl','lastleg-service','surefire:test','-Dtest=FleetTripCalendarServiceTest,FleetTripCalendarMapperTest'],check=False)
raise SystemExit(result.returncode)
+1 -5
View File
@@ -1,10 +1,6 @@
# 单测试环境数据库空库初始化
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
客户“确认计费重”统一使用 `fl_inbound_fee` 最新已推送费用版本及其客户确认记录,不再维护独立重量确认表或接口。源建表定义、旧创建脚本与空库基线已移除;已有 TEST 先发布移除旧引用的 Firstleg 版本,再执行 `migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql` 删除 `fl_chargeable_weight_confirmation`,不修改费用、订单或测量数据。脚本存在不表示已执行或部署。
客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。
费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。
@@ -1,7 +0,0 @@
-- 排车新编号所需独立结构;仅新建序列表,不改写已有排车业务号或业务数据。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
@@ -0,0 +1,15 @@
-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。
-- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
@@ -1,4 +0,0 @@
-- 客户“确认计费重”统一确认 fl_inbound_fee 的当前推送费用版本。
-- 先发布已移除旧重量确认接口及查询的 Firstleg 版本,再执行本脚本。
-- 仅删除旧独立重量确认记录,不修改费用快照、订单或测量数据。
DROP TABLE IF EXISTS baiyue_firstleg.fl_chargeable_weight_confirmation;
@@ -6856,18 +6856,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
+1 -86
View File
@@ -1656,12 +1656,6 @@ 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 '尾程单号',
@@ -4888,83 +4882,4 @@ CREATE TABLE ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
PRIMARY KEY (dispatch_id),
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT ck_owned_business_resource_mode CHECK (
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照',
created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识',
updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id),
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL)
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识',
reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明',
snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version),
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
@@ -11,8 +11,6 @@ enum BusinessFileCategory {
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -73,11 +71,6 @@ enum BusinessFileCategory {
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
void validateOwner(long tenantId) {
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
}
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
@@ -1,14 +0,0 @@
openapi: 3.0.3
info:
title: 自有业务排车运营照片归属契约
version: 1.0.0
description: >-
自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。
复用InternalBusinessFileController,不增加公网File路径。
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
paths: {}
@@ -1,125 +0,0 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.file.Path;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OwnedBusinessPhotoFileTest {
private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO";
private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO";
@TempDir Path root;
@BeforeAll static void columns() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class);
}
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1});
for (long owner : new long[]{-1, 1, 900})
assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class);
for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""})
assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class);
verifyNoInteractions(files, storage);
}
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
var category = BusinessFileCategory.require(type);
for (String format : new String[]{"jpeg", "png"}) {
byte[] bytes = image(format);
var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes);
var saved = storage.store(0, "71", upload, category);
assertThat(storage.read(saved.objectKey())).isEqualTo(bytes);
}
for (var upload : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
MultipartFile oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class);
verify(oversized, never()).getInputStream();
}
/** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
byte[] bytes = image("png");
var row = new FileObjectEntity();
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length);
row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(bound(query, "status")).isEqualTo("ACTIVE");
assertThat(bound(query, "deleted")).isEqualTo(false);
return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type"))
&& "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null;
});
when(storage.read("owned-photo")).thenReturn(bytes);
assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type);
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes);
clearInvocations(storage);
for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING,
"FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) {
assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
private static Object bound(LambdaQueryWrapper<FileObjectEntity> query, String column) {
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
.matcher(query.getSqlSegment());
assertThat(matcher.find()).as("scope column %s", column).isTrue();
return query.getParamNameValuePairs().get(matcher.group(1));
}
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
return service;
}
private static byte[] image(String format) throws Exception {
var bytes = new ByteArrayOutputStream();
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
return bytes.toByteArray();
}
}
@@ -23,7 +23,6 @@ public interface MerchantWorkbenchFilterMapper {
FROM fl_inbound_order i
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL
AND i.process_status != 'CANCELLED' AND i.measure_review_status='APPROVED'
AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
@@ -1,37 +0,0 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 客户工作台筛选事实的持久化入口。 */
@Repository
public class MerchantWorkbenchFilterRepository {
private final MerchantWorkbenchFilterMapper mapper;
/** 注入客户隔离的筛选查询。 */
public MerchantWorkbenchFilterRepository(MerchantWorkbenchFilterMapper mapper) {
this.mapper = mapper;
}
/** 查询物流主状态对应的订单范围。 */
public List<Long> byProcessStatuses(long tenantId, List<String> statuses) {
return mapper.byProcessStatuses(tenantId, statuses);
}
/** 只返回最新已推送费用仍待客户确认的订单。 */
public List<Long> pendingFees(long tenantId) {
return mapper.pendingFees(tenantId);
}
/** 查询待客户补齐报关资料的订单。 */
public List<Long> pendingCustoms(long tenantId) {
return mapper.pendingCustoms(tenantId);
}
/** 查询客户订单的实际履约片段。 */
public List<MerchantWorkbenchFilters.Fragment> progressFragments(long tenantId, List<Long> orderIds) {
return mapper.progressFragments(tenantId, orderIds);
}
}
@@ -1,30 +1,30 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class MerchantWorkbenchFilterService {
@Resource private MerchantWorkbenchFilterRepository repository;
@Resource private MerchantWorkbenchFilterMapper mapper;
@Resource private UnifiedInboundQueryService unified;
@Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights;
/** 精确详情只读进度;确认计费重待办统一使用最新已推送费用的客户确认状态。 */
/** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
if (request.orderIds() != null) {
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
progress(repository.progressFragments(request.tenantId(), request.orderIds())));
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
}
return new MerchantWorkbenchFilters.Response(
request.mainStatus() == null ? null : repository.byProcessStatuses(request.tenantId(),
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
unified.internalStatuses(request.mainStatus())),
repository.pendingFees(request.tenantId()), repository.pendingCustoms(request.tenantId()),
progress(repository.progressFragments(request.tenantId(), null)));
weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
progress(mapper.progressFragments(request.tenantId(), null)));
}
/** 将实装片段聚合为订单级履约进度。 */
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId));
return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> {
@@ -0,0 +1,57 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
/** 客户独立确认计费重,金额确认和主流程不受影响。 */
@Service
public class ChargeableWeightService {
private final WorkflowFactRepository repository;
/** 注入当前事实和确认历史。 */
public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; }
/** 客户仅能读取本订单已审核的计费重。 */
public List<WorkflowFacts.Weight> read(long tenant,long orderId) {
return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList();
}
/** 客户工作台按独立确认事实计算待办,不借用费用确认。 */
public List<Long> pendingOrders(long tenant) {
var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet<Long>();
for(int offset=0;offset<ids.size();offset+=500) for(var f:repository.facts(ids.subList(offset,Math.min(offset+500,ids.size()))))
if(f.tenantId()==tenant && eligible(f) && !weight(f).confirmed()) orders.add(f.orderId());
return List.copyOf(orders);
}
/** 值和测量轮次变化使旧确认失效,金额变化不影响计费重。 */
public static String fingerprint(WorkflowFacts.Fact f) {
try { String value=f.inboundId()+"|"+f.chargeableWeightKg().stripTrailingZeros().toPlainString()+"|"+f.measureCompletedAt();
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** 未审核或非正数不允许确认。 */
public static boolean eligible(WorkflowFacts.Fact f) { return "APPROVED".equals(f.reviewStatus()) && f.chargeableWeightKg()!=null && f.chargeableWeightKg().signum()>0; }
/** 只认可当前版本的确认。 */
public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) {
String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint());
return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null);
}
/** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */
@Transactional
public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) {
if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符");
var inbound=repository.lock(tenant,id);
if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在");
var replay=repository.command(tenant,operator,key);
if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重");
var facts=repository.facts(List.of(id));
if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重");
var f=facts.get(0);
if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过");
var current=weight(f);
if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认");
if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now()));
return weight(repository.facts(List.of(id)).get(0));
}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import jakarta.validation.Valid;
import java.util.List;
/** 客户计费重确认入口,独立于费用确认。 */
@RestController
@RequestMapping("/api/merchant/v1/first-leg")
public class MerchantChargeableWeightController {
private final ChargeableWeightService service;
/** 注入独立确认服务。 */
public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; }
/** 客户范围取认证上下文。 */
@GetMapping("/orders/{orderId}/chargeable-weights")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<List<WorkflowFacts.Weight>> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); }
/** 不触发费用确认与金额锁定。 */
@com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重")
@PostMapping("/chargeable-weights/{inboundId}/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<WorkflowFacts.Weight> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); }
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.workflow;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 柜事实与最新费用确认状态的只读 SQL 入口。 */
/** 柜事实和独立确认SQL入口。 */
@Mapper
public interface WorkflowFactMapper {
String ACTUAL="""
@@ -13,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,"
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,i.chargeable_weight_kg,i.measure_completed_at,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready "
+"EXISTS(SELECT 1 "+ACTUAL+" AND (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 "
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
/** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */
@Select("""
<script>
SELECT i.tenant_id, i.order_id,
CASE WHEN f.customer_confirmed_at IS NULL THEN 'WAIT_WEIGHT_CONFIRM'
ELSE 'WEIGHT_CONFIRMED' END AS main_status
FROM fl_inbound_order i
JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id
WHERE i.deleted=0 AND i.process_status != 'CANCELLED'
AND i.measure_review_status='APPROVED' AND f.fee_status='SENT'
AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer
WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id
AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id)))
AND i.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
</script>
""")
List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(@Param("ids") List<Long> ids);
/** 锁定客户自己的入仓单,串行确认与测量写入。 */
@Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE")
com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id);
/** 客户订单下全部独立入仓单。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id")
List<Long> orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
/** 客户工作台只读取审核通过且有正计费重的来源。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id")
List<Long> weightCandidates(@Param("tenantId") long tenantId);
/** 成员幂等键不允许跨确认对象复用。 */
@Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}")
WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key);
/** 独立记录,不修改费用或物流主状态。 */
@Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})")
int insert(WorkflowFacts.Confirmation value);
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 本地柜事实与费用确认状态的只读持久化入口。 */
/** 本地柜事实与确认记录的持久化入口。 */
@Repository
public class WorkflowFactRepository {
private final WorkflowFactMapper mapper;
@@ -9,8 +9,14 @@ public class WorkflowFactRepository {
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
/** 空候选不读取数据。 */
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
/** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */
public List<com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow> feeConfirmationRows(List<Long> ids) {
return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids);
}
/** 锁定所属客户来源。 */
public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); }
/** 查询订单下独立来源。 */
public List<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
/** 客户计费重待确认候选。 */
public List<Long> weightCandidates(long tenant) { return mapper.weightCandidates(tenant); }
/** 读取成员确认命令。 */
public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); }
/** 追加确认历史。 */
public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); }
}
@@ -1,12 +1,17 @@
package com.baiyue.firstleg.workflow;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 订单业务页签的只读事实契约。 */
/** 页签事实与独立计费重确认契约。 */
public final class WorkflowFacts {
/** 仅承载事实查询契约,不允许实例化。 */
/** 仅承载查询及确认契约,不允许实例化。 */
private WorkflowFacts() {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {}
public record Source(long tenantId,long inboundId) {}
public record Request(List<Source> sources) {}
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {}
public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {}
public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {}
}
@@ -22,11 +22,6 @@ 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()) {
@@ -51,10 +46,11 @@ 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) {
@@ -0,0 +1,13 @@
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
@@ -355,7 +355,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
post:
summary: 查询单一客户工作台履约筛选标识(Order内部)
description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
security: [{internalServiceToken: []}]
requestBody:
required: true
@@ -388,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
@@ -0,0 +1,64 @@
openapi: 3.0.3
info: {title: 客户独立计费重确认, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights:
get:
summary: 客户读取本票全部独立入仓计费重
description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。
parameters:
- {name: orderId, in: path, required: true, schema: {type: integer, format: int64}}
responses:
'200':
description: 标准ApiResponse,认证客户无可确认来源时返回空数组
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
type: array
items: {$ref: '#/components/schemas/Weight'}
/api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm:
post:
summary: 客户独立确认当前计费重
description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [expectedFingerprint]
properties:
expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'}
responses:
'200':
description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态
content:
application/json:
schema:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/Weight'}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
Weight:
type: object
required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed]
properties:
inboundId: {type: string, description: 独立来源入仓单ID}
orderId: {type: string, description: 独立订单ID}
chargeableWeightKg: {type: number}
fingerprint: {type: string}
confirmed: {type: boolean}
confirmedAt: {type: string, format: date-time, nullable: true}
@@ -19,7 +19,6 @@ class MerchantWorkbenchFilterMapperTest {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))");
jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'");
jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)");
@@ -34,7 +33,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(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')");
jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')");
jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)");
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
@@ -9,22 +9,25 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class MerchantWorkbenchFilterServiceTest {
/** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */
@Test void customerQueueUsesPublishedFeeConfirmation() {
var repository=mock(MerchantWorkbenchFilterRepository.class);
/** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */
@Test void customerQueueUsesIndependentWeightConfirmation() {
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"repository",repository);
when(repository.pendingFees(11)).thenReturn(List.of(101L));
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"weights",weights);
when(weights.pendingOrders(11)).thenReturn(List.of(101L));
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
assertThat(response.feeOrderIds()).containsExactly(101L);
verify(repository).pendingFees(11);
verify(weights).pendingOrders(11);
verify(mapper,never()).pendingFees(anyLong());
}
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
var mapper=mock(MerchantWorkbenchFilterRepository.class);
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var unified=mock(UnifiedInboundQueryService.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"repository",mapper);
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"unified",unified);
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -13,7 +15,8 @@ import static org.mockito.Mockito.*;
class WorkflowBoundaryTest {
/** 真实开船来源的测试事实。 */
private WorkflowFacts.Fact fact() {
return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false);
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
}
/** 下游漏来源或返回其他客户均整体失败。 */
@@ -33,4 +36,28 @@ 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,15 +1,18 @@
package com.baiyue.firstleg.workflow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
/** 取消回显、分柜隔离和资料页签回归。 */
/** 取消回显、分柜隔离和独立计费重版本回归。 */
class WorkflowBusinessTest {
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false);
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 time=LocalDateTime.of(2026,10,19,10,0);
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
@@ -28,12 +31,12 @@ class WorkflowBusinessTest {
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 构造独立分柜来源。 */
private WorkflowFacts.Fact fact(long id,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false);
private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
}
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
@Test void cancellationRestoresDispatchQueue() {
var f=fact(1,true);
var f=fact(1,"100",null,true);
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
@@ -44,10 +47,17 @@ class WorkflowBusinessTest {
}
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
@Test void independentSlicesAndUploadedClearance() {
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
}
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
@Test void independentWeightVersion() {
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
}
}
@@ -5,16 +5,16 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.mybatis.spring.SqlSessionTemplate;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import java.nio.file.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 不创建独立重量确认表,验证资料归属和最新费用版本驱动的确认流转。 */
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
class WorkflowFactPersistenceTest {
/** 测试建表不连接业务数据库。 */
@Test void clearanceAndPublishedFeeConfirmationUseCurrentFacts() throws Exception {
@Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
db.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)");
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
@@ -29,6 +29,7 @@ class WorkflowFactPersistenceTest {
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
@@ -38,16 +39,15 @@ class WorkflowFactPersistenceTest {
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class);
assertThat(containers.hasActualVesselDeparture(5)).isOne();
assertThat(containers.hasActualVesselDeparture(6)).isZero();
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
assertThat(containers.hasActualVesselDeparture(6)).isZero();
var repository=new WorkflowFactRepository(mapper);
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
assertThat(service.read(22,101)).isEmpty();
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
@@ -92,42 +92,29 @@ class WorkflowFactPersistenceTest {
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
});
// 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。
var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class);
org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any()))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false))));
var tabs=new WorkflowTabService(repository,downstream);
var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity();
inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).containsExactly(101L);
// 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。
db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"))
.noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"));
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)");
assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22");
assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM"))
.noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED"));
assertThat(workbench.pendingFees(11)).containsExactly(101L);
assertThat(workbench.pendingFees(22)).containsExactly(102L);
db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1");
assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty();
assertThat(workbench.pendingFees(11)).isEmpty();
db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1");
var current=service.read(11,101).get(0);
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
// 父单仅作汇总时,不重复进入业务页签。
assertThat(service.read(11,101)).hasSize(1);
assertThat(service.read(11,103)).hasSize(1);
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3));
assertThat(service.read(11,101)).isEmpty();
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var child=service.read(11,103).get(0);
assertThat(child.confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
}
}
@@ -98,12 +98,6 @@ VALUES
(5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,0),
(5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,0),
(5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,0),
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0),
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
(5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0),
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
@@ -167,7 +161,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -1,45 +0,0 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */
class FleetBusinessPermissionSeedSqlContractTest {
/** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */
@Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception {
String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(String sql:List.of(local,unified)) {
for(var scope:List.of("owned-business","outsourced-business")) {
long base=scope.equals("owned-business")?5107390L:5107400L;
assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'");
assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'");
assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'");
assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'");
}
}
}
/** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */
@Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception {
String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'",
"role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0");
}
/** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */
@Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception {
String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for(long id:List.of(5107400L,5107401L,5107402L)) {
var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
.map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList();
assertThat(grants).hasSize(1);
assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))");
}
}
}
-1
View File
@@ -85,7 +85,6 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -24,10 +24,6 @@
既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。
### 自有车辆运营视图读取边界(2026-10-20)
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
### 平台自营统计边界
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
@@ -36,7 +32,7 @@
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环、fleet不反向导入dispatch/delivery、dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去这条聚合环。
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
@@ -9,14 +9,6 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
const ownedBusinessReadEdges = [
['model.OwnedBusinessModels', 'DispatchViews'],
['service.OwnedBusinessProjection', 'DispatchContracts'],
['service.OwnedBusinessQueryService', 'DispatchContracts'],
['service.OwnedBusinessQueryService', 'DispatchTypes'],
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
const report = analyze();
@@ -50,13 +42,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen
const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain');
assert.equal(policyClasses.length, 3);
for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.')));
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', 'shared-technical']);
assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']);
for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) {
assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters');
}
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false);
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
@@ -66,48 +58,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
&& edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway'
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController'
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService'
&& edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'),
&& edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'),
'dispatch must use fleet ports and guards, not its persistence mappers');
const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(incomingEdges.length > 0);
assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only');
});
test('owned business reads only reviewed dispatch ports and cannot inherit persistence access', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.fleet.ownedbusiness.';
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
const edge = { source: prefix + 'service.OwnedBusinessQueryService', target: 'com.baiyue.lastleg.dispatch.' + target };
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
const unreviewed = { source: 'com.baiyue.lastleg.fleet.trip.FleetTripService', target: 'com.baiyue.lastleg.dispatch.port.DispatchDetailReader' };
report.classDependencies.push(unreviewed);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
});
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
const report = analyze();
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
report.classDependencies.push(edge);
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
}
});
function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
@@ -28,9 +28,8 @@ 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,
@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)));
@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)));
}
/** 只列出当前拥有者未失效草稿。 */
@GetMapping("/drafts")
@@ -1,24 +0,0 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.springframework.stereotype.Component;
/** 只读委托既有排车应用服务,运营域不引用主流程实现或持久化实体。 */
@Component
public class DispatchDetailReadAdapter implements DispatchDetailReader {
private final DispatchAggregateService dispatch;
/** 复用唯一原排车详情提供者,不建立第二套查询规则。 */
public DispatchDetailReadAdapter(DispatchAggregateService dispatch) {
this.dispatch = dispatch;
}
/** 原单资格与可见性仍由原应用服务核对。 */
@Override
public DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo) {
return dispatch.detail(actor, dispatchNo);
}
}
@@ -9,21 +9,6 @@ import java.util.List;
@Mapper
public interface DispatchCandidateMapper {
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
String DEPARTED_PLAN =
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
"ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" +
"SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
"ON dl.id=da.line_id LEFT JOIN ll_order departure_order " +
"ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " +
"(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" +
"CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" +
"SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " +
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
String CONFIRMED_APPOINTMENT_AT =
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
@@ -56,7 +41,7 @@ public interface DispatchCandidateMapper {
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
String AVAILABLE_PLAN_BODY =
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
@@ -68,16 +53,16 @@ public interface DispatchCandidateMapper {
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
"CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
@@ -85,11 +70,11 @@ public interface DispatchCandidateMapper {
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
@@ -123,13 +108,12 @@ public interface DispatchCandidateMapper {
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
@@ -166,7 +150,7 @@ public interface DispatchCandidateMapper {
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
String SELECTED_PLAN_SQL = "<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
@@ -177,16 +161,16 @@ public interface DispatchCandidateMapper {
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
"AND pos.available_packages &gt; 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0) " +
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
@@ -194,11 +178,11 @@ public interface DispatchCandidateMapper {
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
@@ -232,8 +216,7 @@ public interface DispatchCandidateMapper {
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages &gt; 0)) " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
@@ -278,8 +261,7 @@ public interface DispatchCandidateMapper {
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
"AND pos.hold_packages &gt; 0 AND pos.pallet_id &gt; 0 " +
"AND pos.hold_packages &gt; 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
@@ -301,9 +283,8 @@ 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," +
// 从唯一实箱汇总并用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," +
"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," +
"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 " +
@@ -1,65 +0,0 @@
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> TEXT_FIELDS=Set.of(("customerId,customerName,planNo,orderType,containerNo,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptNo").split(","));
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->column(field,fields.contains(field)?"s."+field:
("returnCartonSource".equals(field)?"0":"NULL"))).collect(Collectors.joining(","))
+" FROM ("+body+") s";
}
/** 在UNION前统一文本列,避免表字段、CAST及聚合结果采用不同collation而报1271;数值与时间保持原类型。 */
private static String column(String field,String expression) {
return (TEXT_FIELDS.contains(field)?"CONVERT("+expression+" USING utf8mb4) COLLATE utf8mb4_unicode_ci":expression)+" AS "+field;
}
/** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */
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->column(f,"NULL")).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>";}
}
}
@@ -1,22 +0,0 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
@Mapper
public interface DispatchNumberSequenceMapper {
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
int allocate(@Param("date") LocalDate date);
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -11,8 +11,6 @@ public interface DispatchSourceMapper {
long allocatedPackages, long pickedPackages, long version) { }
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
record PalletReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages,String inboundCode) { }
record PalletReturnedReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages) { }
record Claim(Long id, Long dispatchId, String draftToken) { }
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
record NativeApplication(String customerId,String relabelNo) { }
@@ -114,27 +112,6 @@ public interface DispatchSourceMapper {
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
/** 同客户整页实托一次读齐库存版本,仍保留原仓身份供服务校验。 */
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,"
+ "available_packages AS availablePackages,hold_packages AS holdPackages,allocated_packages AS allocatedPackages,"
+ "picked_packages AS pickedPackages,version FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<Stock> stocks(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */
@Select("<script>SELECT warehouse_pallet_id AS palletId,id,order_id AS orderId,inventory_position_id AS inventoryPositionId,"
+ "received_packages AS packages,inbound_code AS inboundCode FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
List<PalletReceipt> palletReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */
@Select("<script>SELECT p.pallet_id AS palletId,r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,"
+ "r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY r.id</script>")
List<PalletReturnedReceipt> palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
@@ -1,7 +0,0 @@
package com.baiyue.lastleg.dispatch.port;
/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */
public interface DispatchDetailReader {
/** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */
DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo);
}
@@ -12,8 +12,7 @@ public final class DispatchPlanningModels {
private DispatchPlanningModels() { }
public record CandidateQuery(String warehouseId, List<Long> planIds, String orderNo,
String destinationWarehouseCode, String sourceStage, String draftToken,
int page, int size, String warehouseTab) { }
String destinationWarehouseCode, String sourceStage, String draftToken) { }
/** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */
public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion,
@@ -37,9 +36,7 @@ public final class DispatchPlanningModels {
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
long packageCount) { }
/** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete,
long total, int page, int size, long pages) { }
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
List<String> sourceKeys) { }
@@ -1,29 +0,0 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
@Repository
public class DispatchNumberSequence {
private final DispatchNumberSequenceMapper mapper;
/** 流水与排车主单使用相同服务数据库。 */
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(LocalDate date) {
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
mapper.allocate(date);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
return value;
}
}
@@ -11,13 +11,6 @@ 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;
@@ -82,24 +75,6 @@ public class DispatchSourceRepository {
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
/** 同一实托的退回实收不得从来源证明丢失。 */
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
/** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */
public record SourceFacts(List<DispatchSourceMapper.Stock> stock,List<DispatchSourceMapper.Receipt> receipts,
List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts) { }
/** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */
public java.util.Map<Long,SourceFacts> sourceFacts(String customer,List<Long> pallets) {
if(pallets.isEmpty())return java.util.Map.of();
var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId));
var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList())));
var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList())));
var result=new java.util.LinkedHashMap<Long,SourceFacts>();
for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())),
List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of()))));
return java.util.Collections.unmodifiableMap(result);
}
/** 释放已到期草稿键。 */
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
/** 锁定当前活动资源占用。 */
@@ -10,7 +10,6 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.trip.FleetExecutionModels;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -32,8 +31,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@@ -50,7 +47,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
/** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
@@ -148,17 +144,11 @@ 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()),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();
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
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:
@@ -188,15 +178,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
}
long finished=System.nanoTime();
// 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。
if(finished-started>=1_000_000_000L)LOG.warn(
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),
normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(),
(page.total()+normalized.size()-1)/normalized.size());
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
}
/** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */
@@ -285,7 +267,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay = commands.replay(actor,null,identity);
if (replay.isPresent()) return replay.get();
long id=IdWorker.getId();
String number=numbers.next();
String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
@@ -524,10 +506,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
var items=repository.cargo(row.getId());
// 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
lockExecutionSources(row,items);
lockExecutionSources(row,repository.cargo(row.getId()));
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
@@ -543,10 +522,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
// 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。
// 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。
ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(),
context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at);
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
}
@@ -1,33 +0,0 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */
@Component
public class DispatchNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private final DispatchNumberSequence sequences;
private final Clock clock;
/** 正式运行按统一业务时区取当天日期。 */
@Autowired
public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); }
/** 固定时钟用于核实跨午夜归零边界。 */
DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; }
/** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */
public String next() {
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
long value = sequences.next(date);
if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内");
return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value);
}
}
@@ -16,8 +16,6 @@ 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;}
@@ -30,7 +28,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));
@@ -61,8 +59,6 @@ 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;
@@ -93,39 +89,6 @@ 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作业");}
/** 缺失计数不作为业务标识使用。 */
@@ -4,7 +4,6 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -35,64 +34,16 @@ public class DispatchService {
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
var reads=new CandidateReadScope(rows);
var result=new LinkedHashMap<String,SelectionGroup>();
for(var row:rows) {
String key=wholeSourceKey(selection(row,null));
if(result.containsKey(key))continue;
var group=sourceGroup(row,false,reads);
var group=sourceGroup(row,false);
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
}
return Collections.unmodifiableMap(result);
}
/** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */
private final class CandidateReadScope {
private final Map<String,Set<Long>> loaded=new HashMap<>();
private final Map<String,Map<Long,List<DispatchPalletCandidate>>> available=new HashMap<>();
private final Map<String,Map<Long,SourceFacts>> sourceFacts=new HashMap<>();
/** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */
private CandidateReadScope(List<DispatchPalletCandidate> rows) {
var pallets=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null)
pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId());
pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids)));
loadFacts(rows);
}
/** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */
private List<DispatchPalletCandidate> read(String warehouse,List<Long> pallets) {
var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>());
var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>());
var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList();
if(!missing.isEmpty()) {
var rows=wholeSources.physicalAvailable(warehouse,missing);
for(var row:rows)
byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row);
loadFacts(rows);
known.addAll(missing);
}
return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList();
}
/** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */
private void loadFacts(Collection<DispatchPalletCandidate> rows) {
var missing=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null
&& !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId()))
missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId());
missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>())
.putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets))));
}
/** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */
private SourceFacts facts(DispatchPalletCandidate row) {
return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失");
}
}
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
@@ -127,11 +78,6 @@ public class DispatchService {
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
return sourceGroup(seed,locked,null);
}
/** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) {
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
@@ -144,9 +90,7 @@ public class DispatchService {
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
reasons.addAll(facts.blockReasons());
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
.filter(id->id!=null && id>0).forEach(pallets::add);
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
@@ -165,8 +109,7 @@ public class DispatchService {
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
if(!pallets.isEmpty()) {
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
reads.read(seed.overseasWarehouseId(),List.copyOf(pallets));
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
for(var entry:buckets.entrySet()) {
@@ -187,8 +130,7 @@ public class DispatchService {
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
}
}
if(reads!=null)reads.loadFacts(rows.values());
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads));
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
var sources=new ArrayList<SelectionGroupSource>();
@@ -212,22 +154,32 @@ public class DispatchService {
public record WholeSource(DispatchPalletCandidate candidate,
com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { }
/** 总数与当前页同事务读取;页外候选不参与逐组装配。 */
public record CandidatePage(List<DispatchPalletCandidate> rows,long total) { }
/** 草稿所有权先验证,数据库分页后才读取测量和原生退货物理身份。 */
public CandidatePage candidatePage(String operator,CandidateQuery query) {
/** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */
public List<DispatchPalletCandidate> wholeCandidates(String operator,
com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.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,"不能读取其他操作者的草稿来源");
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);
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();
}
/** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public List<WholeSource> lockWholeSources(String warehouseId,
@@ -445,7 +397,7 @@ public class DispatchService {
}
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
var facts = new java.util.TreeMap<String,Object>();
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
@@ -457,15 +409,12 @@ public class DispatchService {
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
facts.put("cartonMarks",row.relabelCartonMarks());
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false),
wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
var stock=basis.stock();
var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
facts.put("stock",stock);
facts.put("receipts",basis.receipts());
facts.put("returnedReceipts",basis.returnedReceipts());
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
return wholeHashes.source(facts);
}
@@ -27,9 +27,11 @@ public class ReturnRelabelDispatchStock {
return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo));
}
/** 分页后仅核实本页原生退货实托;身份失效明确拒绝,不能静默缩页并返回错误总数。 */
public void resolvePage(List<DispatchPalletCandidate> rows) {
if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查");
/** 整车候选只解析已经按仓限定且有界的原生退货记录。 */
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);
}
/** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */
@@ -1,80 +0,0 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business")
public class OutsourcedBusinessController {
private final OutsourcedBusinessService service;
private final OwnedBusinessSupport access;
/** 会话只转真实平台操作者及其原权限集合。 */
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
/** 同筛选分页和完整阶段统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
/** 列表权限包含详情,不借原排车或自有写权限。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
/** 原主单和本页附加历史保持各自版本字段。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
}
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
}
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
}
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.content());
}
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
}
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
}
@@ -1,48 +0,0 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
public final class OutsourcedBusinessModels {
/** DTO容器不允许实例化。 */
private OutsourcedBusinessModels() {}
public static final String LIST="platform:fleet:outsourced-business:list";
public static final String EDIT="platform:fleet:outsourced-business:edit";
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
public record PhotoSummary(int loadingCount) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
public record HistoryPage(List<History> records,int page,int size,int total) {}
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
}
@@ -1,94 +0,0 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
@Service
public class OutsourcedBusinessService {
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
}
/** 三个页签读取同一原排车详情和外配附加记录。 */
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
var result=queries.history(actor,number,page,size,SCOPE);
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
}
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
var ops=request.operations();
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
}
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
}
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
if(request==null)throw bad("照片绑定请求缺失");
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
}
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
}
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
var original=photos.command(actor,number,key,SCOPE);Object result=null;
if(original.result()!=null)result=switch(original.action()) {
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
default->throw conflict("原命令类型不属于外配业务");
};
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
}
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
private Detail detail(OwnedBusinessModels.Detail detail) {
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
}
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
private Row row(OwnedBusinessModels.Row r,String carrierName) {
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
}
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
private Operations operations(OwnedBusinessModels.Operations ops) {
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
return new Operations(ops.money(),ops.arrivals());
}
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
private Photo photo(OwnedBusinessModels.Photo p) {
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
}
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
}
@@ -1,78 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/** 只读原排车装车后事实,仅附加登记和照片可写。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/owned-business")
public class OwnedBusinessController {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessSupport access;
/** Controller不直调Repository/Mapper,身份只取会话。 */
public OwnedBusinessController(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
this.queries=queries;this.records=records;this.photos=photos;this.access=access;
}
/** 列表和完整统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(queries.list(access.actor(user),query));}
/** list权限包含独立详情。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(queries.detail(access.actor(user),dispatchNo));}
/** 原排车和附加历史在同一快照分页,不逐条GET。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
return ApiResponse.success(queries.history(access.actor(user),dispatchNo,page,size));
}
/** 金额可以更正/清空,但不推进主单或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(records.save(access.actor(user),dispatchNo,request));
}
/** 上传仅准备File,关联保存另有独立原键。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(photos.upload(access.actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定失败保留fileId和原请求,不重新上传。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(photos.bind(access.actor(user),dispatchNo,slot,request));
}
/** 附件只允许当前或精确历史引用读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
var result=photos.content(access.actor(user),dispatchNo,slot,fileId,revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.content());
}
/** 不自动补写或向别的账号披露原命令。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey));
}
}
@@ -1,86 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.LocalDateTime;
import java.util.Set;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
/** File保存照片,本域另行授权原排车及分类引用。 */
@Component
public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
public static final long MAX_SIZE=10L*1024*1024;
private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}";
private final RestClient client;
private final String token;
/** 沿既有内部File客户端,保持外部访问域封闭。 */
public HttpOwnedBusinessFileGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token;
}
/** MIME和字节签名一起校验,File仍执行其权威校验。 */
public static void validate(Upload upload) {
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content()))
throw bad("照片须为非空真实JPG/PNG,最大10MiB");
}
/** 使用原图片字节,未知结果不能换键或自动重传。 */
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
validate(upload);var body=new MultipartBodyBuilder();
body.part("file",new ByteArrayResource(upload.content()){
/** 保留本轮原文件名,不生成占位证据。 */
@Override public String getFilename(){return upload.fileName();}
}).contentType(MediaType.parseMediaType(upload.contentType()));
var result=client.post().uri(PATH,slot.businessType(),dispatchId).header("X-Internal-Service-Token",token)
.header("X-Operator-Id",operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().body(Envelope.class);
return verified(result,dispatchId,slot,null);
}
/** 不能借另一客户、另一分类或另一排车的文件授权。 */
@Override public Evidence metadata(long dispatchId,PhotoSlot slot,long fileId) {
return verified(client.get().uri(PATH+"/{file}",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(Envelope.class),dispatchId,slot,fileId);
}
/** 防止把错误页或其他内容冒充图片返回。 */
@Override public byte[] content(long dispatchId,PhotoSlot slot,long fileId) {
var info=metadata(dispatchId,slot,fileId);
byte[] bytes=client.get().uri(PATH+"/{file}/content",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(byte[].class);
if(bytes==null || bytes.length!=info.sizeBytes() || !signature(info.contentType(),bytes))throw conflict("图片内容与元数据不一致");
return bytes;
}
/** 所有归属及内容元数据必须严格匹配原dispatch内部ID。 */
public static Evidence verified(Envelope response,long dispatchId,PhotoSlot slot,Long expected) {
if(response==null || !"200".equals(response.code()) || response.data()==null)throw conflict("照片服务未返回明确成功");
var m=response.data();
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType())
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
}
/** 不放开PDF、SVG或任意image类型。 */
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);}
/** 使用真实文件头,不转换或伪造证据图片。 */
private static boolean signature(String type,byte[] bytes) {
if(bytes==null)return false;
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
}
/** 基础设施错误不反向依赖应用Service,保持单向包依赖。 */
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 不明确的文件结果保留为业务冲突,由上传账本记录。 */
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
public record Envelope(String code,Metadata data) {}
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes,LocalDateTime createdAt) {}
}
@@ -1,15 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
/** 本页平台图片受控代理,不接收浏览器指定的内部主键或业务类型。 */
public interface OwnedBusinessFileGateway {
record Upload(String fileName,String contentType,byte[] content) {}
record Evidence(long fileId,String fileName,String contentType,long sizeBytes,String uploadedAt) {}
/** 同一原意图只上传一次,未知结果由账本保存。 */
Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload);
/** 核验平台、原排车、分类、文件身份和图像格式。 */
Evidence metadata(long dispatchId,PhotoSlot slot,long fileId);
/** 只代理业务层已确认当前或历史引用的内容。 */
byte[] content(long dispatchId,PhotoSlot slot,long fileId);
}
@@ -1,94 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 本页4表唯一SQL入口;不更新排车、库存或财务表。 */
@Mapper
public interface OwnedBusinessMapper {
/** 读取已经冻结的装车归属,不查询当前车辆推断历史。 */
@Select("SELECT * FROM ll_fleet_owned_business_ownership WHERE dispatch_id=#{id}")
Ownership ownership(long id);
/** 在主装车事务保存唯一归属证据,重复交由提供者核对原事实。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_ownership(dispatch_id,vehicle_id,resource_mode,vehicle_ownership,
vehicle_record_version,ownership_captured_at,loading_completion_hash,loading_completed_at)
VALUES(#{dispatchId},#{vehicleId},#{resourceMode},#{vehicleOwnership},#{vehicleRecordVersion},
#{ownershipCapturedAt},#{loadingCompletionHash},#{loadingCompletedAt})
""")
int capture(Ownership row);
/** GET不使用此方法;首个写事务建立可锁定的附加0版本行。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_record(id,dispatch_id,record_version,arrivals_json,photos_json,
created_by,updated_by,created_at,updated_at) VALUES(#{id},#{dispatchId},0,'[]','[]',
#{createdBy},#{updatedBy},#{createdAt},#{updatedAt}) ON DUPLICATE KEY UPDATE dispatch_id=dispatch_id
""")
int ensure(Record row);
/** 只锁本页附加行,不反向申请车辆或派送写锁。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id} FOR UPDATE")
Record lockRecord(long id);
/** 详情只读附加信息,无记录返回虚拟0版本。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id}")
Record record(long id);
/** 页内批量读取,避免逐车补充GET和SQL。 */
@Select("<script>SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Record> records(@Param("ids") List<Long> ids);
/** CAS仅修改运营附加字段,不占用主单recordVersion。 */
@Update("""
UPDATE ll_fleet_owned_business_record SET record_version=#{r.recordVersion},currency=#{r.currency},
total_amount=#{r.totalAmount},actual_distance_miles=#{r.actualDistanceMiles},
fuel_consumption_litres_per_100km=#{r.fuelConsumptionLitresPer100Km},arrivals_json=#{r.arrivalsJson},
photos_json=#{r.photosJson},updated_by=#{r.updatedBy},updated_at=#{r.updatedAt}
WHERE dispatch_id=#{r.dispatchId} AND record_version=#{expected}
""")
int update(@Param("r") Record row,@Param("expected") long expected);
/** 命令键全车唯一,操作者在服务端另行核验。 */
@Select("SELECT * FROM ll_fleet_owned_business_command WHERE dispatch_id=#{id} AND idempotency_key=#{key}")
Command command(@Param("id") long id,@Param("key") String key);
/** 完整意图在文件HTTP之前单独事务提交。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_command(id,dispatch_id,operator_id,idempotency_key,action,
expected_version,request_hash,request_json,status,result_json,error_code,created_at,updated_at)
VALUES(#{id},#{dispatchId},#{operatorId},#{idempotencyKey},#{action},#{expectedVersion},
#{requestHash},#{requestJson},#{status},#{resultJson},#{errorCode},#{createdAt},#{updatedAt})
""")
int insertCommand(Command row);
/** 上传只完成原待定意图,不能把未知结果当新命令重做。 */
@Update("""
UPDATE ll_fleet_owned_business_command SET status=#{status},result_json=#{resultJson},
error_code=#{errorCode},updated_at=#{updatedAt} WHERE id=#{id} AND status='PENDING'
""")
int finishUpload(Command row);
/** 附加写与不可变历史同事务,版本不能重复。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_history(id,dispatch_id,record_version,action,operator_id,
reason,snapshot_json,occurred_at) VALUES(#{id},#{dispatchId},#{recordVersion},#{action},#{operatorId},
#{reason},#{snapshotJson},#{occurredAt})
""")
int insertHistory(History row);
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
@Select("""
SELECT * FROM (
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
UNION ALL
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
""")
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
/** 历史总数不取当前页长度。 */
@Select("SELECT (SELECT COUNT(*) FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id})+(SELECT COUNT(*) FROM ll_dispatch_history WHERE dispatch_id=#{id})")
int historyCount(long id);
/** 历史文件只能从指定真实历史快照读取。 */
@Select("SELECT * FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} AND record_version=#{version}")
History revision(@Param("id") long id,@Param("version") long version);
}
@@ -1,61 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
@Mapper
public interface OwnedBusinessQueryMapper {
String SOURCE="""
SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version,
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
""";
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
String FILTER="""
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
AND o.vehicle_ownership=#{q.scope.ownership}
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
AND o.loading_completed_at=d.loading_completed_at
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
<if test='q.to != null'> AND d.planned_departure_at &lt; #{q.to}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.keyword != null'> AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%')
OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i
WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%'))))</if>
""";
/** 只返回当前完整条件下的一车一行。 */
@Select("<script>"+SOURCE+" WHERE "+FILTER+" ORDER BY d.planned_departure_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<Source> page(@Param("q") Search query);
/** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */
@Select("<script>SELECT d.stage,COUNT(*) AS quantity FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id WHERE "+FILTER+" GROUP BY d.stage</script>")
List<StageCount> summary(@Param("q") Search query);
/** 先读原主单和可空归属,以明确区别无快照与不存在。 */
@Select(SOURCE+" WHERE d.dispatch_no=#{number}")
Source find(String number);
/** 页内全部货物一次读取,各客户保持原归属。 */
@Select("<script>SELECT id,dispatch_order_id,warehouse_pallet_id,customer_id,customer_name,source_key,order_no,inbound_code,pallet_no,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,id</script>")
List<CargoRow> cargo(@Param("ids") List<Long> ids);
/** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */
@Select("""
<script>SELECT a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,
p.appointment_start_utc AS start_utc,SUM(a.package_count) AS allocated_packages
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_item i
ON i.id=a.dispatch_item_id AND i.dispatch_order_id=a.dispatch_id AND i.customer_id=a.customer_id
JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
WHERE a.dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
GROUP BY a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,p.appointment_start_utc
ORDER BY a.dispatch_id,a.destination_key,p.appointment_no</script>
""")
List<Batch> batches(@Param("ids") List<Long> ids);
}
@@ -1,64 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Set;
/** 原排车只读投影和附加信息,不公开第二套履约命令。 */
public final class OwnedBusinessModels {
/** 工具容器不允许实例化。 */
private OwnedBusinessModels() {}
public static final String LIST="platform:fleet:owned-business:list";
public static final String EDIT="platform:fleet:owned-business:edit";
public static final String UPLOAD="platform:fleet:owned-business:upload";
public enum Stage { AFTER_LOADING, DELIVERING, DELIVERED }
public enum PhotoSlot {
LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT, FUEL_START, FUEL_END;
/** 图片框分类不表示司机审核或真实派送签收。 */
public String category() { return name().startsWith("FUEL_")?"FUEL":"LOADING"; }
/** 文件类型与租户归属由本域固定,浏览器不能指定。 */
public String businessType() { return category().equals("FUEL")?"OVERSEAS_DISPATCH_FUEL_PHOTO":"OVERSEAS_DISPATCH_LOADING_PHOTO"; }
}
public record Actor(String operatorId,String operatorName,Set<String> permissions) {}
public record Query(@Min(1) Integer page,@Min(1) @Max(200) Integer size,
String plannedDepartureFrom,String plannedDepartureTo,@Size(max=128) String keyword,
Stage stage,@Size(max=64) String warehouseId) {}
public record Money(@NotBlank @Pattern(regexp="CNY|USD") String currency,
@NotBlank @Pattern(regexp="(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?") String amount) {}
public record Arrival(@NotBlank @Size(max=256) String destinationKey,String actualArrivedAt) {}
public record Operations(@Valid Money money,String actualDistanceMiles,
String fuelConsumptionLitresPer100Km,@NotNull List<@NotNull @Valid Arrival> arrivals) {}
public record SaveOperations(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
@Size(max=1000) String reason) {}
public record BindPhoto(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
public record Gate(boolean allowed,String blockReason) {}
public record Actions(Gate edit,Gate upload) {}
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
public record Vehicle(String vehicleId,String plate,String ownership) {}
public record Driver(String name,String phone) {}
public record Appointment(String appointmentNo,String confirmationNo,String scheduledAt,String allocatedPackages) {}
public record Destination(String destinationKey,String warehouseCode,String warehouseName,String address,
List<Appointment> appointments,String actualArrivedAt) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,
String sizeBytes,String uploadedAt,String savedAt) {}
public record PhotoSummary(int loadingCount,int fuelCount) {}
public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt,
String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver,
List<Destination> destinations,Integer actualPalletCount,Operations operations,String unitPrice,
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
String inboundCode,String palletNo,String destinationKey,String packages) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {}
public record HistoryPage(List<History> records,int page,int size,int total) {}
public record Command(String dispatchNo,String action,String idempotencyKey,String state,
String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
public record Snapshot(Operations operations,List<Photo> photos,String operatorName) {}
}
@@ -1,57 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 仅供本包持久化,不向浏览器输出内部主键。 */
public final class OwnedBusinessRows {
/** 持久化容器不允许实例化。 */
private OwnedBusinessRows() {}
@Data public static class Ownership {
private Long dispatchId,vehicleId,vehicleRecordVersion;
private String resourceMode,vehicleOwnership,loadingCompletionHash;
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
}
@Data public static class Source {
private Long id,recordVersion;
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
private LocalDateTime plannedDepartureAt,loadingCompletedAt,departedAt,deliveryCompletedAt;
private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash;
private Long ownedVehicleId,ownedVehicleRecordVersion;
private LocalDateTime ownedLoadingCompletedAt;
}
/** 原表货物只读字段;不是可写排车实体,不复制来源状态机。 */
@Data public static class CargoRow {
private Long id,dispatchOrderId,warehousePalletId;
private String customerId,customerName,sourceKey,orderNo,inboundCode,palletNo;
private String destinationType,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,deliveryAddress,postalCode;
private Integer packageCount;
}
@Data public static class Record {
private Long id,dispatchId,recordVersion;
private String currency,arrivalsJson,photosJson,createdBy,updatedBy;
private BigDecimal totalAmount,actualDistanceMiles,fuelConsumptionLitresPer100Km;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class Command {
private Long id,dispatchId,expectedVersion;
private String operatorId,idempotencyKey,action,requestHash,requestJson,status,resultJson,errorCode;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class History {
private Long id,dispatchId,recordVersion,dispatchRecordVersion;
private String eventSource,action,operatorId,reason,snapshotJson;
private LocalDateTime occurredAt;
}
@Data public static class Batch {
private Long dispatchId;
private String destinationKey,appointmentNo,confirmationNo;
private LocalDateTime startUtc;
private Long allocatedPackages;
}
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
@Data public static class StageCount { private String stage;private Long quantity; }
}
@@ -1,26 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
public enum OwnedBusinessScope {
OWNED("OWNED","platform:fleet:owned-business:"),
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
private final String ownership,prefix;
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
public String getOwnership(){return ownership;}
/** 查看权限包含本作用域详情、历史和照片读取。 */
public String listPermission(){return prefix+"list";}
/** 运营写权限独立,外配权限不映射成自有权限。 */
public String editPermission(){return prefix+"edit";}
/** 照片写权限独立,不扩大到原排车。 */
public String uploadPermission(){return prefix+"upload";}
/** 外配只支持装车五框,服务调用同样不能绕过。 */
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
/** 不存在只显示当前业务归属,不泄露内部身份。 */
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
}
@@ -1,10 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.port;
import java.time.Instant;
/** 装车主事务唯一捕获车辆归属,不创建第二套履约状态。 */
public interface OwnedBusinessOwnershipCapture {
/** 主流程已稳定车辆资格,仅捕获原装车时点的不可变归属。 */
void capture(long dispatchId, Long vehicleId, String resourceMode,
String loadingCompletionHash, Instant loadingCompletedAt);
}
@@ -1,53 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.repository;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 只封装本页Mapper,关系库访问不使用JdbcTemplate或直连JDBC。 */
@Repository
public class OwnedBusinessRepository {
private final OwnedBusinessMapper records;
private final OwnedBusinessQueryMapper queries;
/** 注入唯一SQL提供者,Service不能直接访问Mapper。 */
public OwnedBusinessRepository(OwnedBusinessMapper records,OwnedBusinessQueryMapper queries){this.records=records;this.queries=queries;}
/** 原归属已捕获后不按当前车辆回算。 */
public Ownership ownership(long id){return records.ownership(id);}
/** 归属与主装车事务共同提交。 */
public void capture(Ownership row){records.capture(row);}
/** 服务端整车分页。 */
public List<Source> page(Search query){return queries.page(query);}
/** 与分页相同过滤的完整统计。 */
public List<StageCount> summary(Search query){return queries.summary(query);}
/** 读取唯一原排车身份及归属证据。 */
public Source source(String number){return queries.find(number);}
/** 页内货物批量查询。 */
public List<CargoRow> cargo(List<Long> ids){return ids.isEmpty()?List.of():queries.cargo(ids);}
/** 页内实际预约分配批量查询。 */
public List<Batch> batches(List<Long> ids){return ids.isEmpty()?List.of():queries.batches(ids);}
/** GET只读,无记录保留虚拟0版本。 */
public Record record(long id){return records.record(id);}
/** 页内附加信息批量读取。 */
public List<Record> records(List<Long> ids){return ids.isEmpty()?List.of():records.records(ids);}
/** 首个写事务建立行,并按dispatch唯一键串行。 */
public Record lock(Record seed){records.ensure(seed);return records.lockRecord(seed.getDispatchId());}
/** 只CAS修改附加信息。 */
public int update(Record row,long expected){return records.update(row,expected);}
/** 命令先按全车请求键定位,再在服务端检查原操作者。 */
public Command command(long id,String key){return records.command(id,key);}
/** 命令与附加版本同事务保存,或作为独立上传意图持久化。 */
public void command(Command row){records.insertCommand(row);}
/** 上传结果只完成原待定意图。 */
public int finishUpload(Command row){return records.finishUpload(row);}
/** 同一附加版本只记录一次不可变历史。 */
public void history(History row){records.insertHistory(row);}
/** 历史总数只取原dispatch。 */
public int historyCount(long id){return records.historyCount(id);}
/** 历史采用稳定版本倒序。 */
public List<History> history(long id,int offset,int size){return records.history(id,offset,size);}
/** 下载历史附件时精确定位原版本。 */
public History revision(long id,long version){return records.revision(id,version);}
}
@@ -1,62 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Command;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 附加操作与文件准备共用二元唯一账本,不允许异账号复用键。 */
@Service
public class OwnedBusinessCommandService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessSupport codec;
/** 文件意图独立短事务,不持业务行锁跨HTTP。 */
public OwnedBusinessCommandService(OwnedBusinessRepository repository,OwnedBusinessSupport codec){this.repository=repository;this.codec=codec;}
public record Reservation(Command command,boolean created) {}
/** 查找原命令时先核操作者,不披露别人的原载荷。 */
public Command existing(long dispatchId,String key,Actor actor) {
var row=repository.command(dispatchId,key);
if(row!=null && !Objects.equals(row.getOperatorId(),actor.operatorId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"该请求键不属于当前操作账号");
return row;
}
/** 原action、版本和完整规范请求必须一致,不能用当前版本重造旧意图。 */
public void same(Command row,String action,Long version,Object request) {
if(!Objects.equals(row.getAction(),action) || !Objects.equals(row.getExpectedVersion(),version)
|| !Objects.equals(row.getRequestHash(),codec.hash(java.util.Arrays.asList(row.getOperatorId(),action,version,request))))throw conflict("相同请求键的操作、版本或内容不同");
}
/** 完整命令实体只用于本页,不保存浏览器凭据。 */
public Command create(long id,Actor actor,String key,String action,Long version,Object request,String status) {
var now=codec.utc(Instant.now());var row=new Command();row.setId(IdWorker.getId());row.setDispatchId(id);
row.setOperatorId(actor.operatorId());row.setIdempotencyKey(key);row.setAction(action);row.setExpectedVersion(version);
row.setRequestHash(codec.hash(java.util.Arrays.asList(actor.operatorId(),action,version,request)));row.setRequestJson(codec.write(request));row.setStatus(status);row.setCreatedAt(now);row.setUpdatedAt(now);return row;
}
/** 首次上传先持久化意图,竞争者只能读取原状态,不再次调用File。 */
@Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
public Reservation reserveUpload(long id,Actor actor,String key,Object request) {
var previous=existing(id,key,actor);
if(previous!=null){same(previous,"UPLOAD_PHOTO",null,request);return new Reservation(previous,false);}
var row=create(id,actor,key,"UPLOAD_PHOTO",null,request,"PENDING");
try{repository.command(row);return new Reservation(row,true);}
catch(DuplicateKeyException race){var original=existing(id,key,actor);if(original==null)throw race;same(original,"UPLOAD_PHOTO",null,request);return new Reservation(original,false);}
}
/** 已知上传回执或未知结果只完成原意图,不创建新业务记录。 */
@Transactional(propagation=Propagation.REQUIRES_NEW)
public void finishUpload(Command command,String status,Object result,String errorCode) {
command.setStatus(status);command.setResultJson(result==null?null:codec.write(result));command.setErrorCode(errorCode);
command.setUpdatedAt(codec.utc(Instant.now()));
if(repository.finishUpload(command)!=1)throw conflict("上传命令结果已被其他操作确认,请核对原请求");
}
/** 写命令与附加更新、历史在同一外部事务提交。 */
public void success(long id,Actor actor,String key,String action,long version,Object request,Object result) {
var command=create(id,actor,key,action,version,request,"SUCCEEDED");command.setResultJson(codec.write(result));repository.command(command);
}
}
@@ -1,52 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 装车时车辆归属的唯一捕获实现,GET绝不补造历史。 */
@Service
public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapture {
private final OwnedBusinessRepository repository;
private final VehicleRepository vehicles;
/** 使用真实车辆提供者,不对车辆主档增加反向写锁。 */
public OwnedBusinessOwnershipService(OwnedBusinessRepository repository,VehicleRepository vehicles){this.repository=repository;this.vehicles=vehicles;}
/** 主流程原父事务已经稳定资源,捕获失败必须与装车一起回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void capture(long dispatchId,Long vehicleId,String resourceMode,String loadingCompletionHash,Instant loadingCompletedAt) {
if(dispatchId<=0 || loadingCompletionHash==null || loadingCompletionHash.isBlank() || loadingCompletionHash.length()>128 || loadingCompletedAt==null
|| !loadingCompletedAt.equals(loadingCompletedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)))throw bad("装车归属证据不完整");
if(!"MASTER".equals(resourceMode) && !"EXTERNAL".equals(resourceMode))throw bad("车辆资源模式不合法");
if("MASTER".equals(resourceMode)?vehicleId==null || vehicleId<=0:vehicleId!=null)throw bad("车辆资源身份不一致");
var existing=repository.ownership(dispatchId);
if(existing!=null){same(existing,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);return;}
var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode);
if("MASTER".equals(resourceMode)) {
var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在"));
if(vehicle.id()!=vehicleId.longValue())throw conflict("车辆归属提供者返回的身份不一致");
if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效");
if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确");
row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion());
} else {row.setVehicleOwnership("DISPATCHED");row.setVehicleRecordVersion(null);}
row.setLoadingCompletedAt(LocalDateTime.ofInstant(loadingCompletedAt,ZoneOffset.UTC));
row.setOwnershipCapturedAt(row.getLoadingCompletedAt());row.setLoadingCompletionHash(loadingCompletionHash);
try{repository.capture(row);}catch(DuplicateKeyException duplicate){
var raced=repository.ownership(dispatchId);if(raced==null)throw duplicate;
same(raced,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);
if(!Objects.equals(raced.getVehicleOwnership(),row.getVehicleOwnership()) || !Objects.equals(raced.getVehicleRecordVersion(),row.getVehicleRecordVersion()))throw conflict("装车车辆归属并发变化");
}
}
/** 只允许同一原装车事实重放,不能以新归属重写旧证据。 */
private void same(Ownership row,Long vehicleId,String mode,String hash,Instant at) {
if(!Objects.equals(row.getVehicleId(),vehicleId) || !Objects.equals(row.getResourceMode(),mode)
|| !Objects.equals(row.getLoadingCompletionHash(),hash) || !Objects.equals(row.getLoadingCompletedAt(),LocalDateTime.ofInstant(at,ZoneOffset.UTC)))
throw conflict("原装车车辆归属证据不同");
}
}
@@ -1,116 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 只有绑定成功才保存照片,上传和绑定各自使用稳定键。 */
@Service
public class OwnedBusinessPhotoService {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessRepository repository;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final OwnedBusinessFileGateway files;
/** HTTP远程请求不在附加行写事务内持锁等待。 */
public OwnedBusinessPhotoService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,
OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
this.queries=queries;this.records=records;this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
}
public record UploadIdentity(PhotoSlot slot,String fileName,String contentType,long sizeBytes,String sha256) {}
public record Download(Photo photo,byte[] content) {}
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED);
}
/** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key);
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
if(slot==null || multipart==null)throw bad("请选择照片及分类");
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
if(!reservation.created()) {
if(!"SUCCEEDED".equals(reservation.command().getStatus()))throw conflict("原上传结果尚未明确,请查询原请求,勿换键重传");
return codec.read(reservation.command().getResultJson(),Photo.class);
}
try {
var result=photo(slot,files.upload(source.getId(),slot,actor.operatorId(),payload));
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
} catch(RuntimeException failure) {
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");}
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
}
}
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
return bind(actor,number,slot,request,OwnedBusinessScope.OWNED);
}
/** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot);
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
if(fileId<=0)throw bad("文件编号无效");
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null) {
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
return codec.read(previous.getResultJson(),Detail.class);
}
var verified=photo(slot,files.metadata(source.getId(),slot,fileId));
return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope);
}
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED);
}
/** 五框历史授权仍由同车同版本的真实引用限定。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List<Photo> bound;
if(revision==null)bound=projection.photos(repository.record(source.getId()));
else {
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
}
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing);
return new Download(photo,files.content(source.getId(),slot,fileId));
}
/** 恢复先校验原操作者,不披露他人载荷。 */
@Transactional(readOnly=true)
public Command command(Actor actor,String number,String key) {
return command(actor,number,key,OwnedBusinessScope.OWNED);
}
/** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */
@Transactional(readOnly=true)
public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor);
if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
}
/** 上传回执不填写保存时间,避免假称已经关联成功。 */
private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence evidence) {
return new Photo(slot,slot.category(),String.valueOf(evidence.fileId()),evidence.fileName(),evidence.contentType(),
String.valueOf(evidence.sizeBytes()),evidence.uploadedAt(),null);
}
}
@@ -1,91 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Component;
import java.math.*;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 组合原排车与附加信息;不复制、推进或重新归并订单。 */
@Component
public class OwnedBusinessProjection {
private final OwnedBusinessSupport codec;
/** 所有数值与日期转换沿统一显式规则。 */
public OwnedBusinessProjection(OwnedBusinessSupport codec){this.codec=codec;}
/** 未登记为null,不以默认油耗、金额或到达填充。 */
public Operations operations(Record record) {
if(record==null)return new Operations(null,null,null,List.of());
var money=record.getTotalAmount()==null?null:new Money(record.getCurrency(),record.getTotalAmount().setScale(2).toPlainString());
return new Operations(money,decimal(record.getActualDistanceMiles()),decimal(record.getFuelConsumptionLitresPer100Km()),
codec.list(record.getArrivalsJson(),Arrival.class));
}
/** 照片已绑定才出现在当前记录,无文件不生成占位图。 */
public List<Photo> photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);}
/** 使用原货物确定目的地key,保持与排车及到达登记一致。 */
public String destinationKey(CargoRow item) {
String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),
item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key;
}
/** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */
public int palletCount(Source source) {
var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class);
if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失");
var ids=new HashSet<Long>();
for(var pallet:confirmed.pallets()) {
if(pallet==null || pallet.warehousePalletId()==null || pallet.warehousePalletId()<=0)throw OwnedBusinessSupport.conflict("实际装车托盘身份缺失");
ids.add(pallet.warehousePalletId());
}
return ids.size();
}
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED);
}
/** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
var destinations=new LinkedHashMap<String,Destination>();
for(var item:cargo) {
String key=destinationKey(item);
destinations.computeIfAbsent(key,k->new Destination(k,item.getDestinationWarehouseCode()==null || item.getDestinationWarehouseCode().isBlank()?item.getFbaWarehouseCode():item.getDestinationWarehouseCode(),item.getDestinationWarehouseName(),
item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment(
b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k)));
}
String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString();
var driver=resources.driver();
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
}
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
public String carrierName(Detail detail) {
return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null
?null:detail.dispatch().vehicleInfo().vehicle().carrierName();
}
/** 附加权限独立于原排车写动作。 */
private Gate gate(Actor actor,String permission) {
boolean allowed=actor!=null && actor.permissions()!=null && actor.permissions().contains(permission);
return new Gate(allowed,allowed?null:"没有该业务操作权限");
}
/** 数据库十进制保持显式单位,不以空值替换0。 */
private String decimal(BigDecimal value){return value==null?null:value.toPlainString();}
}
@@ -1,128 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
@Service
public class OwnedBusinessQueryService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final DispatchDetailReader dispatch;
/** 主单详情沿既有真实提供者,列表使用本域批量投影。 */
public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,DispatchDetailReader dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;}
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ListResult list(Actor actor,Query query) {
return listScoped(actor,query,OwnedBusinessScope.OWNED).result();
}
/** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */
public record ScopedListResult(ListResult result,Map<String,String> carrierNames) {}
/** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失");
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope);
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
for(var c:counts) {
int n=Math.toIntExact(c.getQuantity());
switch(c.getStage()){case "AFTER_LOADING"->after=n;case "DELIVERING"->delivering=n;case "DELIVERED"->delivered=n;default->throw conflict("阶段统计事实不合法");}
}
int total=Math.addExact(Math.addExact(after,delivering),delivered);
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()),
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList();
var carriers=new LinkedHashMap<String,String>();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source));
return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers));
}
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
public Detail detailForWrite(Actor actor,Source source) {
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
}
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
projection.photos(repository.record(source.getId())));
}
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
public Source requireOwned(String number) {
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
}
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
public Source requireScoped(String number,OwnedBusinessScope scope) {
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source;
}
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source,OwnedBusinessScope scope) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
}
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED);
}
/** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) {
codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize);
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
return new History(h.getEventSource(),h.getRecordVersion()==null?null:h.getRecordVersion().toString(),
h.getDispatchRecordVersion()==null?null:h.getDispatchRecordVersion().toString(),h.getAction(),snapshot==null?null:snapshot.operatorName(),codec.iso(h.getOccurredAt()),h.getReason(),
snapshot==null?null:snapshot.operations(),snapshot==null?List.of():snapshot.photos());
}).toList(),page,size,repository.historyCount(source.getId()));
}
/** 页码初始1,缺失与非法值不能混同。 */
private int page(Integer value){if(value==null)return 1;if(value<1)throw bad("页码须大于0");return value;}
/** 列表和历史均限制单页读取量。 */
private int size(Integer value){if(value==null)return 20;if(value<1 || value>200)throw bad("每页条数须为1至200");return value;}
/** 空白可选条件归一化,无限长输入明确拒绝。 */
private String optional(String value,int max){if(value==null || value.isBlank())return null;if(value.length()>max)throw bad("筛选条件过长");return value.trim();}
}
@@ -1,136 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 运营信息和分类照片只写本页附加版本,绝不改主单版本或发布费用。 */
@Service
public class OwnedBusinessRecordService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessProjection projection;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessSupport codec;
/** 业务规则经Repository读写,命令与历史一起提交。 */
public OwnedBusinessRecordService(OwnedBusinessRepository repository,OwnedBusinessQueryService queries,
OwnedBusinessProjection projection,OwnedBusinessCommandService commands,OwnedBusinessSupport codec) {
this.repository=repository;this.queries=queries;this.projection=projection;this.commands=commands;this.codec=codec;
}
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,SaveOperations request) {
return save(actor,number,request,OwnedBusinessScope.OWNED);
}
/** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) {
codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null))
throw bad("外配业务不支持里程或油耗登记");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
var ops=normalize(source,request.operations());var row=lock(source,actor);
previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
requireVersion(row,version);
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
row.setArrivalsJson(codec.write(ops.arrivals()));
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope);
}
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED);
}
/** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) {
codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot);
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
Object identity=new PhotoBinding(slot,request);
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
if(slot!=verified.slot() || !Objects.equals(slot.category(),verified.category()) || !Objects.equals(request.fileId(),verified.fileId()))throw bad("照片不属于本框");
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
requireVersion(row,version);
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
}
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) {
return switch(slot) {
case LOADING_SCENE -> "保存装车现场照片";
case LOADING_LIST -> "保存装车清单照片";
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
case DRIVER_LICENSE -> "保存驾驶证附件照片";
case RECEIPT -> "保存签收凭证照片";
case FUEL_START -> "保存起始油耗表盘照片";
case FUEL_END -> "保存结束油耗表盘照片";
};
}
/** 照片框也参与摘要,不能换URL框位复用原键。 */
public record PhotoBinding(PhotoSlot slot,BindPhoto request) {}
/** 先校验所有目的地及数值,再进入附加行写事务。 */
private Operations normalize(Source source,Operations ops) {
Money money=null;
if(ops.money()!=null) {
if(!Set.of("CNY","USD").contains(ops.money().currency()==null?"":ops.money().currency()))throw bad("币种须为CNY或USD");
String amount=codec.decimal(ops.money().amount(),16,2);if(amount==null)throw bad("金额与币种必须同时登记");
money=new Money(ops.money().currency(),amount);
}
if(ops.arrivals()==null)throw bad("目的地到达登记不完整");
var destinations=new HashSet<String>();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item));
var seen=new HashSet<String>();var arrivals=new ArrayList<Arrival>();
for(var arrival:ops.arrivals()) {
if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车");
var at=codec.instant(arrival.actualArrivedAt());arrivals.add(new Arrival(arrival.destinationKey(),at==null?null:at.toString()));
}
arrivals.sort(Comparator.comparing(Arrival::destinationKey));
return new Operations(money,codec.decimal(ops.actualDistanceMiles(),9,3),
codec.decimal(ops.fuelConsumptionLitresPer100Km(),9,3),List.copyOf(arrivals));
}
/** 首次写建立0版本附加行,仅在成功事务中最终提交。 */
private Record lock(Source source,Actor actor) {
var seed=new Record();seed.setId(IdWorker.getId());seed.setDispatchId(source.getId());seed.setCreatedBy(actor.operatorId());
seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed);if(row==null)throw conflict("附加记录初始化失败");return row;
}
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) {
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope);
commands.success(source.getId(),actor,key,action,expected,request,result);return result;
}
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
private Detail result(OwnedBusinessRows.Command command) {
if(!"SUCCEEDED".equals(command.getStatus()))throw conflict("原命令结果尚未明确,请按原请求恢复");
return codec.read(command.getResultJson(),Detail.class);
}
/** null是未登记,不以0代替。 */
private BigDecimal decimal(String value){return value==null?null:new BigDecimal(value);}
}
@@ -1,94 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JavaType;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** 只处理本页格式、身份和JSON,不读写主流程。 */
@Component
public class OwnedBusinessSupport {
private final ObjectMapper json;
/** 复用服务统一Jackson,不建立第二套日期或版本协议。 */
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
public Actor actor(AuthenticatedUser user) {
return actor(user,OwnedBusinessScope.OWNED);
}
/** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */
public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) {
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
String operator=user.userId()==null?user.username():user.userId().toString();
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
return new Actor(operator,name,user.permissions()==null?Set.of():Set.copyOf(user.permissions()));
}
/** 写权限独立校验,直接Service调用也不能绕过。 */
public void require(Actor actor,String permission) {
if(actor==null || actor.operatorId()==null || actor.permissions()==null || !actor.permissions().contains(permission))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该业务操作权限");
}
/** JSON只从本页记录和已确认排车事实读取,损坏不静默恢复空值。 */
public <T>T read(String value,Class<T> type) {
if(value==null || value.isBlank())throw conflict("缺少可靠业务事实");
try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");}
}
/** 集合记录与声明的元素类型共同校验。 */
public <T>List<T> list(String value,Class<T> type) {
JavaType listType=json.getTypeFactory().constructCollectionType(List.class,type);
try{return json.readValue(value,listType);}catch(Exception error){throw conflict("业务集合事实读取失败");}
}
/** 原请求和原结果完整持久化,序列化失败必须中止命令。 */
public String write(Object value) {
try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("业务记录序列化失败",error);}
}
/** 请求摘要不包含版本漂移后的当前数据。 */
public String hash(Object value){return digest(write(value).getBytes(StandardCharsets.UTF_8));}
/** 文件内容摘要用于识别相同上传原请求,不保存图片进数据库。 */
public String digest(byte[] bytes) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
catch(Exception error){throw new IllegalStateException("无法计算业务摘要",error);}
}
/** 版本0合法,但缺失、负数和超长版本不得降级为0。 */
public long version(String value) {
try{if(value==null || !value.matches("0|[1-9][0-9]*"))throw new IllegalArgumentException();return Long.parseLong(value);}
catch(Exception error){throw bad("请提交有效的原记录版本");}
}
/** 请求键在页面恢复期间保持稳定,不能用空键创建命令。 */
public void key(String key){if(key==null || key.length()<8 || key.length()>128)throw bad("请求键长度须为8至128");}
/** 只接受带时区的真实时间,统一存UTC。 */
public Instant instant(String value) {
try{return value==null?null:OffsetDateTime.parse(value).toInstant();}
catch(Exception error){throw bad("时间须包含明确时区");}
}
/** 关系库时间只表示UTC,不取运行机默认时区。 */
public LocalDateTime utc(Instant value){return value==null?null:LocalDateTime.ofInstant(value,ZoneOffset.UTC);}
/** 展示原真实时间,不根据签收或预计时间填充。 */
public String iso(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
/** 规范十进制输入,空值与明确零分别保存。 */
public String decimal(String value,int integers,int scale) {
if(value==null)return null;
if(!value.matches("(0|[1-9][0-9]{0,"+(integers-1)+"})(\\.[0-9]{1,"+scale+"})?"))throw bad("数量或金额须为非负规范十进制");
return new BigDecimal(value).setScale(scale).toPlainString();
}
/** 用户输入错误保持项目业务错误约定。 */
public static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 业务并发或来源事实不符不能返回模拟成功。 */
public static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
/** 不存在只使用业务提示,不泄露内部主键。 */
public static BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前自有业务");}
}
@@ -179,14 +179,15 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); })
.body(WarehousePalletResponse.class);
requirePalletResponse(response);
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
response.data().unitPrice(), response.data().version());
}
private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
return new ResponseStatusException(status.value() == 409 ? HttpStatus.CONFLICT : status.value() == 404 ? HttpStatus.NOT_FOUND
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
}
@@ -198,23 +199,14 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw palletLookupFailure(result.getStatusCode());
}).body(WarehousePalletResponse.class);
requirePalletResponse(response);
return response;
}
/** 托盘状态冲突是业务拒绝,不能把HTTP 200内的A0409误报为仓储服务不可用。 */
private void requirePalletResponse(WarehousePalletResponse response) {
if (response != null && "A0409".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
response.message() == null || response.message().isBlank() ? "Warehouse pallet state conflicts with this operation" : response.message());
}
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
}
if (response == null || !"200".equals(response.code()) || response.data() == null) {
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response;
}
private WarehousePalletReference reference(WarehousePalletData pallet) {
@@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper {
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
/** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */
@Select("""
<script>
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id))
AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked,
l.order_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
@@ -31,7 +31,7 @@ public interface AppointmentWorkDispatchMapper {
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE <foreach collection='items' item='i' open='(' separator=' OR ' close=')'>(p.id=#{i.planPalletId} AND p.customer_id=#{i.customerId})</foreach>
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,l.order_id,l.destination_type,l.fba_warehouse_code
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,l.order_id,l.destination_type,l.fba_warehouse_code
ORDER BY p.id,l.order_id,l.destination_type,l.fba_warehouse_code LIMIT 20001
</script>
""")
@@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper {
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
@Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List<DispatchPhysical> dispatchPhysicalById(long id);
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
List<Candidate> candidates(long id);
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
@@ -68,9 +68,7 @@ public class RelabelDispatchSourceRepository {
if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
if (position == null) continue;
positions.put(position.getId(), position);
// 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
&& position.getPalletId() > 0
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
}
}
@@ -149,9 +149,8 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
long notified = number(item.getNotifiedPackageCount());
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
|| position.getPalletId() <= 0) {
reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
bound = false;
}
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
@@ -41,12 +41,6 @@ CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
@@ -4559,82 +4553,3 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
PRIMARY KEY (dispatch_id),
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT ck_owned_business_resource_mode CHECK (
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照',
created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识',
updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id),
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
CONSTRAINT ck_owned_business_money CHECK (
(currency IS NULL AND total_amount IS NULL)
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识',
reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明',
snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version),
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
@@ -1,303 +0,0 @@
openapi: "3.0.3"
info:
title: "原排车外配车辆运营视图"
version: "1.0.0"
description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。"
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/outsourced-business:
get:
operationId: listOutsourcedBusiness
summary: "外配列表及同筛选四项完整统计"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: "原排车号、车牌或真实目的仓/FBA编码" }
- { name: warehouseId, in: query, schema: { type: string, maxLength: 64 } }
- { name: plannedDepartureFrom, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间下界(含)" }
- { name: plannedDepartureTo, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间上界(不含)" }
- { name: stage, in: query, schema: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } }
responses:
"200":
description: "完整条件统计不带分页,不逐行GET;响应为统一ApiResponse。"
content:
application/json:
schema: { "$ref": "#/components/schemas/ListResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusiness
summary: "外配原排车组合详情"
x-permission-code: "platform:fleet:outsourced-business:list"
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/history:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusinessHistory
summary: "原排车与运营附加历史分页"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
responses:
"200":
description: "DISPATCH/OPERATIONS保留独立版本,原历史无虚构运营字段。"
content:
application/json:
schema: { "$ref": "#/components/schemas/HistoryResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/operations:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
put:
operationId: saveOutsourcedBusinessOperations
summary: "登记或清空整车承运金额和逐目的仓实际到达"
x-permission-code: "platform:fleet:outsourced-business:edit"
requestBody:
required: true
content:
application/json:
schema: { "$ref": "#/components/schemas/SaveOperations" }
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/uploads:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
post:
operationId: uploadOutsourcedBusinessPhoto
summary: "准备五框照片文件,尚未绑定"
x-permission-code: "platform:fleet:outsourced-business:upload"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file, idempotencyKey]
properties:
file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
responses:
"200":
description: "savedAt=null;未知结果保留原键查询,不重传。"
content:
application/json:
schema: { "$ref": "#/components/schemas/PhotoResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
put:
operationId: bindOutsourcedBusinessPhoto
summary: "以独立稳定键绑定原File编号"
x-permission-code: "platform:fleet:outsourced-business:upload"
requestBody:
required: true
content:
application/json:
schema: { "$ref": "#/components/schemas/BindPhoto" }
responses:
"200": { "$ref": "#/components/responses/DetailResponse" }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/files/{fileId}/content:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
get:
operationId: readOutsourcedBusinessPhotoContent
summary: "读取当前或精确历史版本的同车照片引用"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { name: revisionVersion, in: query, schema: { type: integer, format: int64, minimum: 1 } }
responses:
"200":
description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。"
content:
image/png: { schema: { type: string, format: binary } }
image/jpeg: { schema: { type: string, format: binary } }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
get:
operationId: getOutsourcedBusinessCommand
summary: "仅原操作账号查询原命令结果"
x-permission-code: "platform:fleet:outsourced-business:list"
parameters:
- { name: idempotencyKey, in: path, required: true, schema: { type: string, minLength: 8, maxLength: 128 } }
responses:
"200":
description: "同dispatch唯一账本;相同键异内容/版本冲突,异账号拒绝,未知不自动重试。"
content:
application/json:
schema: { "$ref": "#/components/schemas/CommandResponse" }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
DispatchNo: { name: dispatchNo, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
Slot: { name: slot, in: path, required: true, schema: { "$ref": "#/components/schemas/PhotoSlot" } }
responses:
DetailResponse:
description: "统一ApiResponse;成功code=200,业务拒绝A0400/A0403/A0404/A0409,认证/网关可真实401/403/503;保留requestId/traceId。"
content:
application/json:
schema:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Detail" } ], nullable: true } } }
schemas:
Envelope:
type: object
properties:
code: { type: string }
message: { type: string }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
Operations:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.Operations
required: [money, arrivals]
properties:
money: { allOf: [ { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Money" } ], nullable: true }
arrivals: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Arrival" }, description: "只接受原实际destinationKey,禁止重复或其他车辆目的地;省略目的地或null均不推定到达" }
SaveOperations:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.SaveOperations
required: [recordVersion, idempotencyKey, operations]
properties:
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
operations: { "$ref": "#/components/schemas/Operations" }
reason: { type: string, nullable: true, maxLength: 1000 }
BindPhoto:
type: object
additionalProperties: false
x-java-record: OutsourcedBusinessModels.BindPhoto
required: [recordVersion, idempotencyKey, fileId]
properties:
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
fileId: { type: string, pattern: "^[1-9][0-9]*$" }
Vehicle:
type: object
x-java-record: OutsourcedBusinessModels.Vehicle
required: [vehicleId, plate, ownership, carrierName]
properties:
vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" }
plate: { type: string, nullable: true }
ownership: { type: string, enum: [DISPATCHED] }
carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" }
PhotoSlot:
type: string
enum: [LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT]
Photo:
type: object
x-java-record: OutsourcedBusinessModels.Photo
required: [slot, category, fileId, fileName, contentType, sizeBytes, uploadedAt, savedAt]
properties:
slot: { "$ref": "#/components/schemas/PhotoSlot" }
category: { type: string, enum: [LOADING] }
fileId: { type: string }
fileName: { type: string }
contentType: { type: string, enum: [image/png, image/jpeg] }
sizeBytes: { type: string }
uploadedAt: { type: string, format: date-time }
savedAt: { type: string, format: date-time, nullable: true }
PhotoSummary:
type: object
x-java-record: OutsourcedBusinessModels.PhotoSummary
required: [loadingCount]
properties:
loadingCount: { type: integer, minimum: 0, maximum: 5 }
Row:
type: object
x-java-record: OutsourcedBusinessModels.Row
required: [dispatchNo, stage, plannedDepartureAt, loadingCompletedAt, departedAt, deliveryCompletedAt, origin, vehicle, driver, destinations, actualPalletCount, operations, unitPrice, recordVersion, dispatchRecordVersion, photoSummary, actions, sourceIssues]
properties:
dispatchNo: { type: string }
stage: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" }
plannedDepartureAt: { type: string, format: date-time }
loadingCompletedAt: { type: string, format: date-time }
departedAt: { type: string, format: date-time, nullable: true }
deliveryCompletedAt: { type: string, format: date-time, nullable: true }
origin: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Origin" }
vehicle: { "$ref": "#/components/schemas/Vehicle" }
driver: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Driver" }
destinations: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Destination" } }
actualPalletCount: { type: integer, minimum: 0, description: "原实载warehousePalletId去重;同客户/跨客户共托只计一次,不用标准托位作除数" }
operations: { "$ref": "#/components/schemas/Operations" }
unitPrice: { type: string, nullable: true, description: "整车金额除实际物理托数,HALF_UP两位;金额null或托数0时null" }
recordVersion: { type: string, description: "本页附加版本,初始只读虚拟0;原主单版本独立" }
dispatchRecordVersion: { type: string }
photoSummary: { "$ref": "#/components/schemas/PhotoSummary" }
actions: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Actions" }
sourceIssues: { type: array, items: { type: string } }
ListResult:
type: object
x-java-record: OutsourcedBusinessModels.ListResult
required: [records, page, size, total, summary]
properties:
records: { type: array, items: { "$ref": "#/components/schemas/Row" } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
summary: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Summary" }
Detail:
type: object
x-java-record: OutsourcedBusinessModels.Detail
required: [row, dispatch, cargo, photos]
properties:
row: { "$ref": "#/components/schemas/Row" }
dispatch: { "$ref": "overseas-business-v1.yaml#/components/schemas/DispatchDetail" }
cargo: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Cargo" } }
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
History:
type: object
x-java-record: OutsourcedBusinessModels.History
required: [eventSource, recordVersion, dispatchRecordVersion, action, operatorName, occurredAt, reason, operations, photos]
properties:
eventSource: { type: string, enum: [DISPATCH, OPERATIONS] }
recordVersion: { type: string, nullable: true }
dispatchRecordVersion: { type: string, nullable: true }
action: { type: string }
operatorName: { type: string, nullable: true }
occurredAt: { type: string, format: date-time }
reason: { type: string, nullable: true }
operations: { allOf: [ { "$ref": "#/components/schemas/Operations" } ], nullable: true }
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
HistoryPage:
type: object
x-java-record: OutsourcedBusinessModels.HistoryPage
required: [records, page, size, total]
properties:
records: { type: array, items: { "$ref": "#/components/schemas/History" } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
Command:
type: object
x-java-record: OutsourcedBusinessModels.Command
required: [dispatchNo, action, idempotencyKey, state, recordVersion, replayed, result, errorCode, retryAllowed]
properties:
dispatchNo: { type: string }
action: { type: string, enum: [SAVE_OPERATIONS, UPLOAD_PHOTO, BIND_PHOTO] }
idempotencyKey: { type: string }
state: { type: string, enum: [SUCCEEDED, PENDING, UNKNOWN_RESULT, REJECTED] }
recordVersion: { type: string, nullable: true }
replayed: { type: boolean }
result: { nullable: true, oneOf: [ { "$ref": "#/components/schemas/Detail" }, { "$ref": "#/components/schemas/Photo" } ] }
errorCode: { type: string, nullable: true }
retryAllowed: { type: boolean, enum: [false] }
ListResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/ListResult" } ], nullable: true } } }
HistoryResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/HistoryPage" } ], nullable: true } } }
PhotoResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Photo" } ], nullable: true } } }
CommandResponse:
allOf:
- { "$ref": "#/components/schemas/Envelope" }
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Command" } ], nullable: true } } }
@@ -1,972 +0,0 @@
openapi: "3.0.3"
info:
title: "原排车自有车辆运营视图"
version: "1.0.0"
description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。"
security:
-
bearerAuth: []
paths:
"/api/platform/v1/last-leg/fleet/owned-business":
get:
operationId: "listOwnedBusiness"
summary: "自有车辆装车后视图及同筛选四项统计"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/ListResult"
nullable: true
parameters:
-
name: "page"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
default: 1
-
name: "size"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
maximum: 200
default: 20
-
name: "plannedDepartureFrom"
in: "query"
required: false
schema:
type: "string"
format: "date-time"
-
name: "plannedDepartureTo"
in: "query"
required: false
schema:
type: "string"
format: "date-time"
-
name: "keyword"
in: "query"
required: false
schema:
type: "string"
maxLength: 128
-
name: "stage"
in: "query"
required: false
schema:
"$ref": "#/components/schemas/Stage"
-
name: "warehouseId"
in: "query"
required: false
schema:
type: "string"
maxLength: 64
x-filter-contract: "plannedDepartureAt [from,to),统计同全部过滤、不带分页;无归属快照不降级按当前主档推断。"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
get:
operationId: "getOwnedBusiness"
summary: "组合原排车详情及附加运营信息"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/history":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
get:
operationId: "historyOwnedBusiness"
summary: "原排车及附加真实流水聚合分页"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/HistoryPage"
nullable: true
parameters:
-
name: "page"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
default: 1
-
name: "size"
in: "query"
required: false
schema:
type: "integer"
minimum: 1
maximum: 200
default: 20
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/operations":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
put:
operationId: "saveOwnedBusinessOperations"
summary: "按附加版本登记、更正或清空运营信息"
description: "权限:platform:fleet:owned-business:edit。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:edit"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
requestBody:
required: true
content:
"application/json":
schema:
"$ref": "#/components/schemas/SaveOperations"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/uploads":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
post:
operationId: "uploadOwnedBusinessPhoto"
summary: "逐框准备照片,尚未保存绑定"
description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:upload"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Photo"
nullable: true
requestBody:
required: true
content:
"multipart/form-data":
schema:
type: "object"
required:
- "file"
- "idempotencyKey"
properties:
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。"
file:
type: "string"
format: "binary"
description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
put:
operationId: "bindOwnedBusinessPhoto"
summary: "保存原上传fileId;失败保留原键恢复,不重新上传"
description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:upload"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Detail"
nullable: true
requestBody:
required: true
content:
"application/json":
schema:
"$ref": "#/components/schemas/BindPhoto"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/files/{fileId}/content":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "slot"
in: "path"
required: true
schema:
"$ref": "#/components/schemas/PhotoSlot"
-
name: "fileId"
in: "path"
required: true
schema:
type: "string"
pattern: "^[1-9][0-9]*$"
get:
operationId: "readOwnedBusinessPhoto"
summary: "授权读取当前或原历史绑定图片"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "仅当前或指定历史引用的图片字节,Cache-Control:no-store,X-Content-Type-Options:nosniff"
content:
"image/jpeg":
schema:
type: "string"
format: "binary"
"image/png":
schema:
type: "string"
format: "binary"
parameters:
-
name: "revisionVersion"
in: "query"
required: false
schema:
type: "integer"
format: "int64"
minimum: 1
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/commands/{idempotencyKey}":
parameters:
-
name: "dispatchNo"
in: "path"
required: true
schema:
type: "string"
maxLength: 128
-
name: "idempotencyKey"
in: "path"
required: true
schema:
type: "string"
minLength: 8
maxLength: 128
get:
operationId: "recoverOwnedBusinessCommand"
summary: "恢复本操作者原命令,未知结果不得换键重发"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
200:
description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。"
content:
"application/json":
schema:
type: "object"
properties:
code:
type: "string"
message:
type: "string"
data:
allOf:
-
"$ref": "#/components/schemas/Command"
nullable: true
components:
securitySchemes:
bearerAuth:
type: "http"
scheme: "bearer"
bearerFormat: "JWT"
schemas:
Stage:
type: "string"
enum:
- "AFTER_LOADING"
- "DELIVERING"
- "DELIVERED"
PhotoSlot:
type: "string"
enum:
- "LOADING_SCENE"
- "LOADING_LIST"
- "PRE_DEPARTURE_CHECK"
- "DRIVER_LICENSE"
- "RECEIPT"
- "FUEL_START"
- "FUEL_END"
Money:
type: "object"
x-java-record: "OwnedBusinessModels.Money"
required:
- "currency"
- "amount"
properties:
currency:
type: "string"
enum:
- "CNY"
- "USD"
amount:
type: "string"
pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$"
Arrival:
type: "object"
x-java-record: "OwnedBusinessModels.Arrival"
required:
- "destinationKey"
- "actualArrivedAt"
properties:
destinationKey:
type: "string"
minLength: 1
maxLength: 256
actualArrivedAt:
type: "string"
nullable: true
format: "date-time"
Operations:
type: "object"
x-java-record: "OwnedBusinessModels.Operations"
required:
- "money"
- "actualDistanceMiles"
- "fuelConsumptionLitresPer100Km"
- "arrivals"
properties:
money:
allOf:
-
"$ref": "#/components/schemas/Money"
nullable: true
actualDistanceMiles:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$"
fuelConsumptionLitresPer100Km:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$"
arrivals:
type: "array"
items:
"$ref": "#/components/schemas/Arrival"
SaveOperations:
type: "object"
x-java-record: "OwnedBusinessModels.SaveOperations"
required:
- "recordVersion"
- "idempotencyKey"
- "operations"
properties:
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
operations:
"$ref": "#/components/schemas/Operations"
reason:
type: "string"
nullable: true
maxLength: 1000
BindPhoto:
type: "object"
x-java-record: "OwnedBusinessModels.BindPhoto"
required:
- "recordVersion"
- "idempotencyKey"
- "fileId"
properties:
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
idempotencyKey:
type: "string"
minLength: 8
maxLength: 128
fileId:
type: "string"
pattern: "^[1-9][0-9]*$"
Gate:
type: "object"
x-java-record: "OwnedBusinessModels.Gate"
required:
- "allowed"
- "blockReason"
properties:
allowed:
type: "boolean"
blockReason:
type: "string"
nullable: true
Actions:
type: "object"
x-java-record: "OwnedBusinessModels.Actions"
required:
- "edit"
- "upload"
properties:
edit:
"$ref": "#/components/schemas/Gate"
upload:
"$ref": "#/components/schemas/Gate"
Origin:
type: "object"
x-java-record: "OwnedBusinessModels.Origin"
required:
- "warehouseId"
- "warehouseCode"
- "warehouseName"
properties:
warehouseId:
type: "string"
warehouseCode:
type: "string"
warehouseName:
type: "string"
Vehicle:
type: "object"
x-java-record: "OwnedBusinessModels.Vehicle"
required:
- "vehicleId"
- "plate"
- "ownership"
properties:
vehicleId:
type: "string"
plate:
type: "string"
ownership:
type: "string"
enum:
- "OWNED"
Driver:
type: "object"
x-java-record: "OwnedBusinessModels.Driver"
required:
- "name"
- "phone"
properties:
name:
type: "string"
nullable: true
phone:
type: "string"
nullable: true
Appointment:
type: "object"
x-java-record: "OwnedBusinessModels.Appointment"
required:
- "appointmentNo"
- "confirmationNo"
- "scheduledAt"
- "allocatedPackages"
properties:
appointmentNo:
type: "string"
confirmationNo:
type: "string"
nullable: true
scheduledAt:
type: "string"
nullable: true
format: "date-time"
allocatedPackages:
type: "string"
pattern: "^[1-9][0-9]*$"
Destination:
type: "object"
x-java-record: "OwnedBusinessModels.Destination"
required:
- "destinationKey"
- "warehouseCode"
- "warehouseName"
- "address"
- "appointments"
- "actualArrivedAt"
properties:
destinationKey:
type: "string"
warehouseCode:
type: "string"
nullable: true
warehouseName:
type: "string"
nullable: true
address:
type: "string"
nullable: true
appointments:
type: "array"
items:
"$ref": "#/components/schemas/Appointment"
actualArrivedAt:
type: "string"
nullable: true
format: "date-time"
Photo:
type: "object"
x-java-record: "OwnedBusinessModels.Photo"
required:
- "slot"
- "category"
- "fileId"
- "fileName"
- "contentType"
- "sizeBytes"
- "uploadedAt"
- "savedAt"
properties:
slot:
"$ref": "#/components/schemas/PhotoSlot"
category:
type: "string"
enum:
- "LOADING"
- "FUEL"
fileId:
type: "string"
fileName:
type: "string"
contentType:
type: "string"
enum:
- "image/jpeg"
- "image/png"
sizeBytes:
type: "string"
pattern: "^[1-9][0-9]*$"
uploadedAt:
type: "string"
savedAt:
type: "string"
nullable: true
format: "date-time"
description: "上传回执savedAt为空;绑定成功才填写savedAt。File类型分别为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,平台0、原dispatch内部ID,最多10MiB。"
PhotoSummary:
type: "object"
x-java-record: "OwnedBusinessModels.PhotoSummary"
required:
- "loadingCount"
- "fuelCount"
properties:
loadingCount:
type: "integer"
minimum: 0
fuelCount:
type: "integer"
minimum: 0
Row:
type: "object"
x-java-record: "OwnedBusinessModels.Row"
required:
- "dispatchNo"
- "stage"
- "plannedDepartureAt"
- "loadingCompletedAt"
- "departedAt"
- "deliveryCompletedAt"
- "origin"
- "vehicle"
- "driver"
- "destinations"
- "actualPalletCount"
- "operations"
- "unitPrice"
- "recordVersion"
- "dispatchRecordVersion"
- "photoSummary"
- "actions"
- "sourceIssues"
properties:
dispatchNo:
type: "string"
stage:
"$ref": "#/components/schemas/Stage"
plannedDepartureAt:
type: "string"
format: "date-time"
loadingCompletedAt:
type: "string"
format: "date-time"
departedAt:
type: "string"
nullable: true
format: "date-time"
deliveryCompletedAt:
type: "string"
nullable: true
format: "date-time"
origin:
"$ref": "#/components/schemas/Origin"
vehicle:
"$ref": "#/components/schemas/Vehicle"
driver:
"$ref": "#/components/schemas/Driver"
destinations:
type: "array"
items:
"$ref": "#/components/schemas/Destination"
actualPalletCount:
type: "integer"
minimum: 0
nullable: true
operations:
"$ref": "#/components/schemas/Operations"
unitPrice:
type: "string"
nullable: true
pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
dispatchRecordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
photoSummary:
"$ref": "#/components/schemas/PhotoSummary"
actions:
"$ref": "#/components/schemas/Actions"
sourceIssues:
type: "array"
items:
type: "string"
Summary:
type: "object"
x-java-record: "OwnedBusinessModels.Summary"
required:
- "dispatchCount"
- "afterLoadingCount"
- "deliveringCount"
- "deliveredCount"
properties:
dispatchCount:
type: "integer"
minimum: 0
afterLoadingCount:
type: "integer"
minimum: 0
deliveringCount:
type: "integer"
minimum: 0
deliveredCount:
type: "integer"
minimum: 0
ListResult:
type: "object"
x-java-record: "OwnedBusinessModels.ListResult"
required:
- "records"
- "page"
- "size"
- "total"
- "summary"
properties:
records:
type: "array"
items:
"$ref": "#/components/schemas/Row"
page:
type: "integer"
minimum: 1
size:
type: "integer"
minimum: 1
maximum: 200
total:
type: "integer"
minimum: 0
summary:
"$ref": "#/components/schemas/Summary"
Cargo:
type: "object"
x-java-record: "OwnedBusinessModels.Cargo"
required:
- "sourceKey"
- "customerId"
- "customerName"
- "orderNo"
- "inboundCode"
- "palletNo"
- "destinationKey"
- "packages"
properties:
sourceKey:
type: "string"
customerId:
type: "string"
customerName:
type: "string"
nullable: true
orderNo:
type: "string"
nullable: true
inboundCode:
type: "string"
nullable: true
palletNo:
type: "string"
destinationKey:
type: "string"
packages:
type: "string"
Detail:
type: "object"
x-java-record: "OwnedBusinessModels.Detail"
required:
- "row"
- "dispatch"
- "cargo"
- "photos"
properties:
row:
"$ref": "#/components/schemas/Row"
dispatch:
"$ref": "./overseas-business-v1.yaml#/components/schemas/DispatchDetail"
cargo:
type: "array"
items:
"$ref": "#/components/schemas/Cargo"
photos:
type: "array"
items:
"$ref": "#/components/schemas/Photo"
History:
type: "object"
x-java-record: "OwnedBusinessModels.History"
required:
- "eventSource"
- "recordVersion"
- "dispatchRecordVersion"
- "action"
- "operatorName"
- "occurredAt"
- "reason"
- "operations"
- "photos"
properties:
eventSource:
type: "string"
enum:
- "DISPATCH"
- "OPERATIONS"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
dispatchRecordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
action:
type: "string"
operatorName:
type: "string"
nullable: true
occurredAt:
type: "string"
format: "date-time"
reason:
type: "string"
nullable: true
operations:
allOf:
-
"$ref": "#/components/schemas/Operations"
nullable: true
photos:
type: "array"
items:
"$ref": "#/components/schemas/Photo"
description: "UNION ALL读取原排车真实流水与附加流水,一次排序分页;DISPATCH事件recordVersion=null、operations=null、photos=[];OPERATIONS事件dispatchRecordVersion=null。"
HistoryPage:
type: "object"
x-java-record: "OwnedBusinessModels.HistoryPage"
required:
- "records"
- "page"
- "size"
- "total"
properties:
records:
type: "array"
items:
"$ref": "#/components/schemas/History"
page:
type: "integer"
minimum: 1
size:
type: "integer"
minimum: 1
maximum: 200
total:
type: "integer"
minimum: 0
Command:
type: "object"
x-java-record: "OwnedBusinessModels.Command"
required:
- "dispatchNo"
- "action"
- "idempotencyKey"
- "state"
- "recordVersion"
- "replayed"
- "result"
- "errorCode"
- "retryAllowed"
properties:
dispatchNo:
type: "string"
action:
type: "string"
enum:
- "SAVE_OPERATIONS"
- "UPLOAD_PHOTO"
- "BIND_PHOTO"
idempotencyKey:
type: "string"
state:
type: "string"
enum:
- "SUCCEEDED"
- "PENDING"
- "UNKNOWN_RESULT"
- "REJECTED"
recordVersion:
type: "string"
pattern: "^(0|[1-9][0-9]*)$"
nullable: true
replayed:
type: "boolean"
result:
nullable: true
oneOf:
-
"$ref": "#/components/schemas/Detail"
-
"$ref": "#/components/schemas/Photo"
errorCode:
type: "string"
nullable: true
retryAllowed:
type: "boolean"
enum:
- false
@@ -888,8 +888,8 @@ paths:
'422': { description: 排车单没有可打印的 BOL 明细 }
/api/platform/v1/last-leg/dispatches/candidates:
get:
summary: 分页读取真实候选及来源版本
description: 权限 platform:overseas-warehouse:dispatch:list。数据库按客户、订单、托盘和来源身份稳定排序分页;总数与当前页来自同一只读快照。selectionGroup仍包含完整同托及整申请来源,可包含页外成员;选取时服务端再次权威解析,不以当前页充当完整组。
summary: 读取真实候选及来源版本
description: 权限 platform:overseas-warehouse:dispatch:list。
security:
- bearerAuth: []
responses:
@@ -947,26 +947,6 @@ paths:
in: query
schema:
type: string
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: size
in: query
schema:
type: integer
minimum: 1
maximum: 50
default: 20
- name: warehouseTab
in: query
description: 对起运仓名称和编码的服务端页签筛选,在统计和分页前生效。
schema:
type: string
enum: [ALL, LAX, NYC]
default: ALL
/api/platform/v1/last-leg/dispatches/drafts/pre-arrange:
post:
summary: 将所有非空容器原子转换为整车
@@ -6992,19 +6972,6 @@ components:
nullable: true
complete:
type: boolean
description: 仅当前响应包含筛选后的全部候选时为true;分页中的false不影响每个selectionGroup的完整性。
total:
type: string
pattern: ^[0-9]+$
description: 同一筛选下的来源总行数。
page:
type: integer
size:
type: integer
pages:
type: string
pattern: ^[0-9]+$
required: [candidates, snapshotAt, complete, total, page, size, pages]
DispatchDraftContainer:
type: object
x-java-record: DispatchPlanningModels.DraftContainer
@@ -16,62 +16,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DispatchCandidateMapperContractTest {
/** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */
@Test
void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception {
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) {
var db = connection.createStatement();
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)");
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)");
db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)");
db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)");
for (int id = 1; id <= 9; id++) {
db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')");
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)");
db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")");
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")");
db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")");
if (id != 2) db.execute("INSERT INTO ll_container_shipping_gate VALUES (" + id + ",40,CURRENT_TIMESTAMP)");
}
db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=5 WHERE id IN (3,4)");
db.execute("INSERT INTO ll_inventory_position VALUES ('A',103,5),('A',104,0),('B',104,10)");
db.execute("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=5");
db.execute("UPDATE ll_order SET manual_node_override=TRUE WHERE id IN (6,7,9)");
db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)");
db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)");
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)");
String plan = DispatchCandidateMapper.PLAN_ONLY;
String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) "
+ "AND (" + plan + " 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))";
var ids = new ArrayList<Long>();
try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
while (rows.next()) {
long id = rows.getLong("id"); ids.add(id);
assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage"));
assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class));
}
}
assertEquals(List.of(1L, 3L, 7L), ids);
// 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。
for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) {
assertTrue(sql.contains(eligible.replace("> 0", "&gt; 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id")));
assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId"));
}
}
}
/** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */
@Test
void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception {
String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class,
String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value());
int start = sql.indexOf("CAST(CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id)");
int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)");
int end = sql.indexOf(" AS overseasWarehouseId", start);
assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影");
String expression = sql.substring(start, end).replace("&gt;", ">").replace("&lt;", "<");
@@ -85,16 +35,15 @@ class DispatchCandidateMapperContractTest {
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) {
var statement = connection.createStatement();
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)");
statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))");
statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))");
statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL),"
+ "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL),"
+ "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21),"
+ "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)");
statement.execute("INSERT INTO ll_inbound_plan_pallet(id,plan_id,warehouse_pallet_id) VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
+ "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)");
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (10,1,1000,'RESERVED',0)");
statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001'),"
+ "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001'),"
+ "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')");
@@ -102,7 +51,7 @@ class DispatchCandidateMapperContractTest {
+ "(20,'B','193000000000100001'),(21,'B','193000000000100001')");
try (var prepared = connection.prepareStatement(query)) {
assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L));
assertWarehouseRows(prepared, "193000000000200002", List.of(8L, 10L));
assertWarehouseRows(prepared, "193000000000200002", List.of(8L));
for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong"))
assertWarehouseRows(prepared, invalid, List.of());
}
@@ -266,7 +215,7 @@ class DispatchCandidateMapperContractTest {
Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("AND (" + DispatchCandidateMapper.PLAN_ONLY + " OR EXISTS"));
assertTrue(sql.contains("p.warehouse_pallet_id IS NULL OR EXISTS"));
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
@@ -300,7 +249,7 @@ class DispatchCandidateMapperContractTest {
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY));
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
assertTrue(sql.contains("pos.overseas_warehouse_id"));
assertTrue(sql.contains(method.getName().equals("available")
? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"
@@ -38,9 +38,7 @@ class SmartDispatchOpenApiContractTest {
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode");
assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet())
.containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages");
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode");
var draft=map(map(schemas.get("DispatchDraft")).get("properties"));
assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt");
var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch");
@@ -33,8 +33,8 @@ class SmartDispatchControllerTest {
/** 候选仍委托实际主流程,保留仓库及全部真实筛选。 */
@Test void candidatesKeepWarehouseMultiCustomerFilters(){
controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX");
verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX"));
controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1");
verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1"));
verifyNoInteractions(planning);
}
/** HTTP实际参数采用destinationWarehouseCode,不能因旧名称使前端筛选静默丢失。 */
@@ -43,7 +43,7 @@ class SmartDispatchControllerTest {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of()));
mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseId","92").param("destinationWarehouseCode","LAX9"))
.andExpect(status().isOk());
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning);
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null));verifyNoInteractions(planning);
}
/** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */
@Test void allDraftRoutesDelegateFrozenVersionedRequests(){
@@ -1,26 +0,0 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
/** 运营组合读取必须保留原详情提供者的身份和拒绝语义。 */
class DispatchDetailReadAdapterTest {
/** 不吞原排车资格失败,不切换操作者或降级返回空详情。 */
@Test
void preservesOriginalIdentityAndReadRejection() {
var original = mock(DispatchAggregateService.class);
var actor = new DispatchContracts.Actor("owned-business-reader", DispatchTypes.ActorMode.PLATFORM, null);
var failure = new IllegalArgumentException("原单不可读取");
when(original.detail(actor, "DS-OWNED-READ")).thenThrow(failure);
var adapter = new DispatchDetailReadAdapter(original);
var actual = assertThrows(IllegalArgumentException.class, () -> adapter.detail(actor, "DS-OWNED-READ"));
org.junit.jupiter.api.Assertions.assertSame(failure, actual);
verify(original).detail(actor, "DS-OWNED-READ");
verifyNoMoreInteractions(original);
}
}
@@ -1,127 +0,0 @@
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.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.*;
import java.util.*;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
/** 显式指定TEST配置才运行;只读真实MySQL,不建表、不造数、不输出客户记录或连接凭据。 */
@EnabledIfSystemProperty(named="dispatch.mysql.profile",matches=".+")
class DispatchCandidateMySqlReadOnlyTest {
private Connection connect() throws Exception {
var profile=new HashMap<String,String>();
for(String line:Files.readAllLines(Path.of(System.getProperty("dispatch.mysql.profile")))) {
var match=java.util.regex.Pattern.compile("^\\s*(?:export\\s+)?([A-Z][A-Z0-9_]*)\\s*=\\s*(.*)$").matcher(line);
if(!match.matches())continue;
String value=match.group(2).trim();
if(value.length()>1&&(value.startsWith("\"")&&value.endsWith("\"")||value.startsWith("'")&&value.endsWith("'")))
value=value.substring(1,value.length()-1);
profile.put(match.group(1),value);
}
assertEquals("test",profile.get("CHONGCHONG_ENV"),"只允许显式TEST只读检查");
String url="jdbc:mysql://"+profile.get("CHONGCHONG_DB_HOST")+":"+profile.get("CHONGCHONG_DB_PORT")
+"/baiyue_lastleg?characterEncoding=UTF-8&connectTimeout=5000&socketTimeout=15000";
try {
var connection=DriverManager.getConnection(url,profile.get("CHONGCHONG_DB_USERNAME"),profile.get("CHONGCHONG_DB_PASSWORD"));
connection.setReadOnly(true);
return connection;
} catch(SQLException error) {
throw new AssertionError("MySQL connection failed; SQLState="+error.getSQLState()+", code="+error.getErrorCode());
}
}
/** 用纯SELECT构造冲突,确认原始UNION拒绝混合collation且实际生产投影保留中文/字符身份,无需修改测试库。 */
@Test void mixedCollationsAreNormalizedBeforeUnionForCountAndPage() throws Exception {
var fields=Arrays.stream(DispatchPalletCandidate.class.getDeclaredFields()).filter(f->f.getType()==String.class)
.map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
var project=DispatchCandidatePageMapper.Sql.class.getDeclaredMethod("project",String.class,Set.class,boolean.class);
project.setAccessible(true);
String general="SELECT "+fields.stream().sorted().map(f->"_utf8mb4'洛杉矶-001' COLLATE utf8mb4_general_ci AS "+f).collect(Collectors.joining(","));
String unicode=general.replace("utf8mb4_general_ci","utf8mb4_unicode_ci");
try(var connection=connect();var statement=connection.createStatement()) {
statement.setQueryTimeout(10);
var failure=assertThrows(SQLException.class,()->statement.executeQuery(general+" UNION ALL "+unicode));
assertTrue(Set.of(1267,1271).contains(failure.getErrorCode()),"MySQL应报告collation冲突");
for(boolean count:List.of(true,false)) {
String sql=project.invoke(null,general,fields,count)+" UNION ALL "+project.invoke(null,unicode,fields,count);
try(var rows=statement.executeQuery(sql)) {
int total=0;
while(rows.next()) {
total++;
assertEquals("洛杉矶-001",rows.getString("overseasWarehouseId"));
assertEquals("洛杉矶-001",rows.getString("fbaWarehouseCode"));
if(!count) {
for(String field:fields)assertEquals("洛杉矶-001",rows.getString(field),field);
assertNull(rows.getObject("planPalletId"));
assertNull(rows.getObject("appointmentAt"));
assertNull(rows.getObject("grossWeightKg"));
}
}
assertEquals(2,total);
}
}
}
}
/** 执行MyBatis正式SQL和绑定参数,分别检查真实四来源count/page以及筛选分支;仅输出行数和SQL耗时。 */
@Test void actualCandidateQueriesExecuteOnMySql() throws Exception {
String warehouse=System.getProperty("dispatch.mysql.warehouse");
assertNotNull(warehouse,"指定只读验收仓");
var configuration=new Configuration();
configuration.addMapper(DispatchCandidatePageMapper.class);
List<CandidateQuery> queries=List.of(
new CandidateQuery(warehouse,null,null,null,null,null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,null,null,2,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RELABEL",null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RECEIVED",null,1,20,"LAX"),
new CandidateQuery(warehouse,null,null,null,"PLAN",null,1,20,"NYC"),
new CandidateQuery(warehouse,null,null,"ONT8",null,null,1,20,"ALL"),
new CandidateQuery(warehouse,List.of(-1L),null,null,null,null,1,20,"ALL"));
try(var connection=connect()) {
try(var statement=connection.createStatement();var rows=statement.executeQuery("SELECT @@collation_connection,COLLATION(CAST(1 AS CHAR(128)))")) {
assertTrue(rows.next());
System.out.printf("MYSQL_COLLATION connection=%s castAsChar=%s%n",rows.getString(1),rows.getString(2));
}
try(var statement=connection.createStatement();var rows=statement.executeQuery(
"SELECT TABLE_NAME,COLUMN_NAME,COLLATION_NAME FROM information_schema.COLUMNS "
+"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_order','ll_inbound_plan','ll_return_receipt','ll_relabel_application') "
+"AND COLUMN_NAME IN ('customer_id','overseas_warehouse_id','overseas_warehouse_name','overseas_warehouse_code') ORDER BY TABLE_NAME,COLUMN_NAME")) {
while(rows.next())System.out.printf("MYSQL_COLLATION %s.%s=%s%n",rows.getString(1),rows.getString(2),rows.getString(3));
}
for(var query:queries) {
var parameters=new HashMap<String,Object>();
parameters.put("query",query);parameters.put("offset",(long)(query.page()-1)*query.size());
long total=0;int size=0;long started=System.nanoTime();
for(String method:List.of("count","page")) {
var mapped=configuration.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method);
var bound=mapped.getBoundSql(parameters);
assertTrue(bound.getSql().stripLeading().startsWith("SELECT"));
try(var statement=connection.prepareStatement(bound.getSql())) {
statement.setQueryTimeout(10);
new DefaultParameterHandler(mapped,parameters,bound).setParameters(statement);
try(var rows=statement.executeQuery()) {
if(method.equals("count")) { assertTrue(rows.next());total=rows.getLong(1); }
else while(rows.next()) {
size++;assertEquals(warehouse,rows.getString("overseasWarehouseId"));
if(query.destinationWarehouseCode()!=null)assertEquals(query.destinationWarehouseCode(),rows.getString("fbaWarehouseCode"));
}
}
}
}
assertTrue(size<=query.size());assertTrue(size<=total);
System.out.printf(Locale.ROOT,"MYSQL_READ_ONLY page=%d stage=%s tab=%s total=%d rows=%d countAndPageMs=%.3f%n",
query.page(),query.sourceStage(),query.warehouseTab(),total,size,(System.nanoTime()-started)/1_000_000d);
}
}
}
}
@@ -1,83 +0,0 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 使用真实SQL、连接内流水与Spring事务代理核对唯一性和事务边界。 */
class DispatchNumberSequenceTest {
private static class Fixture {
final JdbcTemplate jdbc;
final DataSourceTransactionManager transactions;
final DispatchNumberSequence sequence;
final DispatchNumberSequenceMapper mapper;
/** 隔离库造表只使用测试 JDBC,被测分配和读取均由实际 Mapper 执行。 */
Fixture() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
jdbc.execute("CREATE TABLE ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)");
var configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class);
var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
sequence=(DispatchNumberSequence)proxy.getProxy();
}
}
/** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception {
var f=new Fixture();var today=LocalDate.of(2026,10,20);
assertThat(f.sequence.next(today)).isEqualTo(1);
assertThat(f.sequence.next(today)).isEqualTo(2);
assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1);
assertThat(f.sequence.next(today)).isEqualTo(3);
}
/** 两个真实事务连接争用同一天主键,全部已提交号码唯一且连续。 */
@Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception {
var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2);
try {
var work=new ArrayList<Callable<Long>>();for(int i=0;i<64;i++)work.add(()->f.sequence.next(date));
var values=new TreeSet<Long>();for(var future:pool.invokeAll(work))values.add(future.get());
assertThat(values).hasSize(64).first().isEqualTo(1L);
assertThat(values.last()).isEqualTo(64L);
} finally {pool.shutdownNow();}
}
/** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */
@Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() throws Exception {
var f=new Fixture();var date=LocalDate.of(2026,10,20);
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback");
})).isInstanceOf(IllegalStateException.class);
assertThat(f.sequence.next(date)).isEqualTo(2);
}
/** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */
@Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() throws Exception {
var f=new Fixture();var date=LocalDate.of(2026,10,20);
f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date);
assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满");
assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L);
}
/** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */
@Test void connectionResultBypassesSessionCacheInTheSameTransaction() throws Exception {
var f=new Fixture();
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
});
}
}
@@ -268,25 +268,12 @@ public class DispatchAggregatePersistenceTest {
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
// 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。
org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing()
.when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class),
anyString(),anyString(),any(java.time.Instant.class));
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)))
.isInstanceOf(IllegalStateException.class).hasMessage("ownership-capture-failed");
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(approved.recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero();
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
org.mockito.Mockito.verify(f.ownedBusinessOwnership,org.mockito.Mockito.times(2)).capture(anyLong(),
eq(context.resources().vehicle().vehicleId()),eq(context.resources().vehicle().mode().name()),
anyString(),any(java.time.Instant.class));
org.mockito.Mockito.verifyNoMoreInteractions(f.ownedBusinessOwnership);
}
/** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */
@@ -457,8 +444,6 @@ public class DispatchAggregatePersistenceTest {
/** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */
public static final class Fixture {
final com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership=
mock(com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture.class);
public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service;
public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session;
public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet;
@@ -536,11 +521,7 @@ public class DispatchAggregatePersistenceTest {
service=new DispatchAggregateService(repository,
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class));
@@ -1,70 +0,0 @@
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.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
class DispatchLoadingEligibilityPersistenceTest {
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
@ParameterizedTest
@ValueSource(booleans={false,true})
void unmaterializedPlanRejectsLoadingBeforePhysicalSourceLock(boolean mixed) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var plan=f.source(1,"101",501,"ONT8",3);
var selections=mixed?List.of(plan,f.source(2,"202",502,"LAX9",2)):List.of(plan);
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("unreceived-loading",selections)));
var approved=f.approve(created);
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN' WHERE plan_pallet_id=1");
if(mixed) {
f.picked(2,"202",502,2);
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=2");
}
var providerRows=mock(RelabelDispatchSourceRepository.class);
var provider=spy(new RelabelDispatchSourceService(providerRows,f.json));
var sources=mock(DispatchSourceRepository.class);
var rules=spy(new DispatchService());
ReflectionTestUtils.setField(rules,"relabelSources",provider);
ReflectionTestUtils.setField(rules,"wholeSources",sources);
ReflectionTestUtils.setField(f.service,"sourceRules",rules);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();
assertThat(preview.loadingComplete().allowed()).isFalse();
assertThat(preview.loadingComplete().blockCode()).isEqualTo("PICKING_INCOMPLETE");
assertThat(preview.pallets()).hasSize(mixed?1:0);
var tables=List.of("ll_dispatch_order","ll_dispatch_item","ll_dispatch_command","ll_dispatch_history",
"ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock",
"ll_fleet_trip","ll_fleet_trip_history");
var before=snapshot(f,tables);
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
assertThat(snapshot(f,tables)).isEqualTo(before);
verify(rules,never()).lockExecutionSources(anyString(),anyList());
verifyNoInteractions(provider,providerRows,sources,f.ownedBusinessOwnership);
}
/** 按实际列值读取CLOB及时间,避免比较JDBC临时对象身份。 */
private Object snapshot(DispatchAggregatePersistenceTest.Fixture f,List<String> tables) {
return tables.stream().map(table->f.db.query("SELECT * FROM "+table+" ORDER BY id",(row,index)->{
var values=new java.util.ArrayList<String>();
for(int column=1;column<=row.getMetaData().getColumnCount();column++)values.add(row.getString(column));
return values;
})).toList();
}
}
@@ -28,45 +28,6 @@ import static org.mockito.Mockito.*;
/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */
class DispatchMergedRelabelPersistenceTest {
/** 原生退货分页按实箱去重计量,不能因申请多明细重复计箱或重量。 */
@Test void nativeReturnPageMeasuresOneCartonOnce() throws Exception {
var f=new Merged(false);var at=LocalDateTime.of(2026,1,1,0,0);
f.insert("ll_return_inventory_carton","id",901,"customer_id","101","overseas_warehouse_id","9",
"carton_mark","NATIVE-CARTON","pallet_code","NATIVE-PALLET","relabel_application_id",201,
"relabel_no","RL-A","status","RELABEL_PENDING","sku_types",1,"total_quantity",2,
"weight",12,"length",100,"width",100,"height",100,"created_at",at,"updated_at",at);
f.f.db.update("UPDATE ll_relabel_item SET source_inventory_position_id=NULL,return_carton_id=901 WHERE id=301");
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","同箱另一明细",
"total_package_count",1,"notified_package_count",1,"carton_mark","NATIVE-CARTON","operation_type","CARTON_LABEL",
"return_carton_id",901,"destination_fba_warehouse_code","ONT8");
var query=new DispatchPlanningModels.CandidateQuery("9",null,"RL-A",null,"RELABEL",null,1,20,"ALL");
var mapper=f.f.session.getMapper(DispatchCandidatePageMapper.class);
assertThat(mapper.count(query)).isOne();
var row=mapper.page(query,0).get(0);
assertThat(row.returnCartonSource()).isTrue();assertThat(row.packageCount()).isOne();
assertThat(row.grossWeightKg()).isEqualByComparingTo("12");assertThat(row.volumeCbm()).isEqualByComparingTo("1");
f.f.db.update("UPDATE ll_return_inventory_carton SET weight=NULL,height=NULL WHERE id=901");
row=mapper.page(query,0).get(0);
assertThat(row.grossWeightKg()).isNull();assertThat(row.volumeCbm()).isNull();
}
/** 数据库限页不能截断同托来源组;总数、翻页及服务端搜索必须一致。 */
@Test void candidatePagesRetainCompleteCrossPageSelectionGroup() throws Exception {
var f=new Merged(false);
var all=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,null,null,1,1,"ALL"));
assertThat(all.total()).isEqualTo(2);assertThat(all.rows()).hasSize(1);
var first=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,1,"ALL"));
var second=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,2,1,"ALL"));
assertThat(first.total()).isEqualTo(2);assertThat(second.total()).isEqualTo(2);
assertThat(first.rows()).hasSize(1);assertThat(second.rows()).hasSize(1);
assertThat(first.rows().get(0).orderRefs()).isNotEqualTo(second.rows().get(0).orderRefs());
var group=f.rules.selectionGroups(first.rows()).values().iterator().next();
assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo).containsExactlyInAnyOrder("RL-A","RL-B");
var filtered=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,"RL-B","ONT8","RELABEL",null,1,1,"ALL"));
assertThat(filtered.total()).isOne();assertThat(filtered.rows()).extracting(r->r.orderRefs()).containsExactly("RL-B");
var empty=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("99",null,null,null,"RELABEL",null,1,1,"ALL"));
assertThat(empty.total()).isZero();assertThat(empty.rows()).isEmpty();
}
/** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */
@ParameterizedTest @ValueSource(booleans={false,true})
void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception {
@@ -187,33 +148,6 @@ class DispatchMergedRelabelPersistenceTest {
var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4);
}
/** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */
@ParameterizedTest @ValueSource(longs={0,-1})
void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception {
var f=new Merged(false);
for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601,
"overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id,
"warehouse_location_code","LOC-1","hold_packages",1);
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源",
"total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING",
"operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102,
"source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING",
"destination_fba_warehouse_code","ONT8");
f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED");
var candidates=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,20,"ALL")).rows();
var groups=f.rules.selectionGroups(candidates);
assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L));
var group=groups.get("RELABEL:RL-A:501");
assertThat(group.complete()).isFalse();
assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失"));
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo)
.containsExactlyInAnyOrder("RL-A","RL-B");
assertThat(group.pallets()).hasSize(1);
assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList()))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
}
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
private static final class Merged {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
@@ -229,25 +163,17 @@ class DispatchMergedRelabelPersistenceTest {
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table);
// H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。
f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","")
.replace("_utf8mb4'","'")
.replace("_utf8mb4", "")
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json);
var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json);
// H2不实现MySQL的字符集转换;这里只验证分页/完整组,collation由真实MySQL只读测试覆盖。
configuration.setDefaultScriptingLanguage(H2CandidateLanguageDriver.class);
configuration.addMapper(DispatchCandidatePageMapper.class);
configuration.setDefaultScriptingLanguage(org.apache.ibatis.scripting.xmltags.XMLLanguageDriver.class);
var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class));
ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class))));
ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class));
@@ -292,11 +218,4 @@ class DispatchMergedRelabelPersistenceTest {
/** 历史由真实提供者解析,固定发生次序不依赖当前系统时钟。 */
void history(long id,long app,String customer,String action,Object detail,int day) throws Exception {insert("ll_relabel_pda_history","id",id,"task_id",1000+app,"application_id",app,"customer_id",customer,"action",action,"to_status","WAIT_REVIEW","operator_key","worker","operator_name","测试","detail_json",f.json.writeValueAsString(detail),"created_at",LocalDateTime.of(2026,1,day,0,0));}
}
public static class H2CandidateLanguageDriver extends org.apache.ibatis.scripting.xmltags.XMLLanguageDriver {
@Override public org.apache.ibatis.mapping.SqlSource createSqlSource(org.apache.ibatis.session.Configuration configuration,String script,Class<?> parameterType) {
return super.createSqlSource(configuration,script.replaceAll(
"CONVERT\\((s\\.\\w+|NULL) USING utf8mb4\\) COLLATE utf8mb4_unicode_ci","$1"),parameterType);
}
}
}
@@ -1,31 +0,0 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
import org.junit.jupiter.api.Test;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DispatchNumberGeneratorTest {
@Test void usesBusinessDateAndFourDigitsWithoutWarehouseOrCustomerSuffix() {
var sequences=mock(DispatchNumberSequence.class);
when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L,42L,9999L);
var numbers=new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T08:00:00Z"),ZoneOffset.UTC));
assertThat(numbers.next()).isEqualTo("PC202610200001");
assertThat(numbers.next()).isEqualTo("PC202610200042");
assertThat(numbers.next()).isEqualTo("PC202610209999");
}
@Test void switchesToTheNewDailySequenceAtShanghaiMidnight() {
var sequences=mock(DispatchNumberSequence.class);
when(sequences.next(LocalDate.of(2026,10,19))).thenReturn(238L);
when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L);
assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610190238");
assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610200001");
}
@Test void rejectsExhaustedOrInvalidSequenceInsteadOfWrappingOrReusingNumbers() {
for(long value:new long[]{0,10000}) {
var sequences=mock(DispatchNumberSequence.class);when(sequences.next(any())).thenReturn(value);
assertThatThrownBy(()->new DispatchNumberGenerator(sequences).next()).isInstanceOf(IllegalStateException.class);
}
}
}
@@ -1,91 +0,0 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */
class DispatchPlanPdaReadPersistenceTest {
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
var f=new Fixture();var before=f.dbState();
var task=f.read();
assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L);
assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3);
assertThat(f.dbState()).isEqualTo(before);
verify(f.sources).stock("101",501L,false);
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
}
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
var f=new Fixture();var row=f.f.candidates.get(1L);
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty();
row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
var f=new Fixture();
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L));
assertThat(f.read().items().get(0).positions()).isEmpty();
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of()));
assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
var f=new Fixture();
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) {
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
assertThat(f.read().items().get(0).positions()).isEmpty();
}
}
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchSourceRepository sources=mock(DispatchSourceRepository.class);
final DispatchService rules=mock(DispatchService.class);
final DispatchPdaQueryService queries;final String number;
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */
Fixture() throws Exception {
var selection=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection))));
f.approve(created);number=created.dispatchNo();
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'");
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1)));
when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of()));
}
/** 读取正式PDA入口。 */
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
/** 比较持久化原明细与主单版本。 */
Object dbState(){return List.of(f.db.queryForList("SELECT * FROM ll_dispatch_item"),f.db.queryForList("SELECT record_version FROM ll_dispatch_order"));}
}
}
@@ -58,16 +58,12 @@ class DispatchServiceTest {
verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial);
}
@Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){
assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
}
@Test void candidatePagePreservesDatabaseOrderAndDoesNotReadOtherRows(){
var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null,2,20,"ALL");
when(sources.candidateCount(query)).thenReturn(55L);
when(sources.candidatePage(query)).thenReturn(List.of(row(1,501,"101"),row(2,502,"102")));
var page=service.candidatePage("operator",query);
assertThat(page.total()).isEqualTo(55);
assertThat(page.rows()).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102");
verify(sources).candidateCount(query);verify(sources).candidatePage(query);verifyNoMoreInteractions(sources);
@Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){
var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null);
when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101")));
assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102");
}
@Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){
var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
@@ -13,67 +13,6 @@ import static org.mockito.ArgumentMatchers.*;
/** 候选投影在此受控,来源订单/仓/库存版本/实收组成与锁序均经实际MyBatis查询。 */
class DispatchWholeSourcePersistenceTest {
/** 同页普通来源批读不随托盘逐行重复,完整组与版本须和单组权威读取完全一致。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={true,false})
void batchesPageSourceReadsWithoutChangingGroupsOrReusingThemAcrossRequests(boolean sameCustomer) throws Exception {
var fixture=new Sources(sameCustomer);var selections=fixture.selections();
var first=fixture.rules.selectionGroup("9",selections.get(0));
var second=fixture.rules.selectionGroup("9",selections.get(1));
clearInvocations(fixture.sourceMapper,fixture.candidateMapper);
var rows=fixture.f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList();
var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
assertThat(groups.get("PLAN:1")).isEqualTo(first);
assertThat(groups.get("PLAN:2")).isEqualTo(second);
verify(fixture.sourceMapper,times(1)).physicalPlans("9",List.of(501L,502L));
verify(fixture.sourceMapper,times(1)).physicalReturns("9",List.of(501L,502L));
if(sameCustomer) {
verify(fixture.candidateMapper,times(1)).readSelected("101",List.of(1L,2L));
verify(fixture.candidateMapper,never()).readSelected("101",List.of(1L));
}
var customers=sameCustomer?Map.of("101",List.of(501L,502L)):Map.of("101",List.of(501L),"202",List.of(502L));
customers.forEach((customer,pallets)->{
verify(fixture.sourceMapper,times(1)).stocks(customer,pallets);
verify(fixture.sourceMapper,times(1)).palletReceipts(customer,pallets);
verify(fixture.sourceMapper,times(1)).palletReturnedReceipts(customer,pallets);
});
verify(fixture.sourceMapper,never()).stock(anyString(),anyLong(),anyBoolean());
verify(fixture.sourceMapper,never()).receipts(anyString(),anyLong());
verify(fixture.sourceMapper,never()).returnedReceipts(anyString(),anyLong());
verify(fixture.sourceMapper,never()).physicalPlans("9",List.of(501L));
fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=4 WHERE id=1");
var refreshed=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
assertThat(refreshed.get("PLAN:1").groupVersion()).isNotEqualTo(first.groupVersion());
verify(fixture.sourceMapper,times(2)).physicalPlans("9",List.of(501L,502L));
}
/** 未实收计划没有实托,整页版本生成不能逐条发空库存和实收查询。 */
@Test void plannedCandidatesNeedNoPhysicalFactQueries() throws Exception {
var fixture=new Sources(true);
var rows=fixture.f.candidates.values().stream().map(row->row.warehousePalletId(null).sourceStage("PLAN")).toList();
var expected=new HashMap<String,String>();
for(var row:rows)expected.put("PLAN:"+row.planPalletId(),fixture.rules.wholeSourceVersion(row,"PLAN:"+row.planPalletId(),false));
clearInvocations(fixture.sourceMapper,fixture.candidateMapper);
var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
for(var entry:expected.entrySet())assertThat(groups.get(entry.getKey()).sources().get(0).selection().sourceVersion()).isEqualTo(entry.getValue());
verifyNoInteractions(fixture.sourceMapper,fixture.candidateMapper);
}
/** 同实托不同客户的收货依据必须隔离,真实退回实收仍参加摘要且保持原顺序。 */
@Test void batchFactsMatchIndividualQueriesForSharedPalletCustomersAndReturns() throws Exception {
var fixture=new Sources();
fixture.f.db.update("INSERT INTO ll_inventory_position VALUES(3,'202',3,501,'PALLET-501','9',2,0,0,0,1)");
fixture.f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(3,'202','IN-OTHER',9002,3,2,501)");
fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(5,'101',1,9001,1,2)");
fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'202',3,9002,3,1)");
var repository=new DispatchSourceRepository(fixture.candidateMapper,mock(ReturnDispatchCandidateMapper.class),fixture.sourceMapper);
for(var customer:List.of("101","202")) {
var batch=repository.sourceFacts(customer,List.of(501L,502L));
for(var pallet:List.of(501L,502L)) {
assertThat(batch.get(pallet).stock()).isEqualTo(repository.stock(customer,pallet,false));
assertThat(batch.get(pallet).receipts()).isEqualTo(repository.receipts(customer,pallet));
assertThat(batch.get(pallet).returnedReceipts()).isEqualTo(repository.returnedReceipts(customer,pallet));
}
}
}
/** 同仓多客户先锁齐来源订单,随后稳定锁计划和库存,不以MIN展示订单替代组成。 */
@Test void resolvesMultipleCustomersAndLocksAllSourceOrdersBeforeInventory() throws Exception {
var fixture=new Sources();var selections=fixture.selections();clearInvocations(fixture.sourceMapper);
@@ -104,12 +43,10 @@ class DispatchWholeSourcePersistenceTest {
/** 实际来源表保持独立客户;只有复杂候选投影查询在本用例隔离。 */
private static final class Sources {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchSourceMapper sourceMapper;final DispatchCandidateMapper candidateMapper;final DispatchService rules=new DispatchService();
final DispatchSourceMapper sourceMapper;final DispatchService rules=new DispatchService();
/** 构造真实订单组成和库存版本,不能由候选响应替代这些数据库事实。 */
Sources() throws Exception { this(false); }
/** 同客户多托用于核对候选批读次数,跨客户夹具继续覆盖原隔离与锁序。 */
Sources(boolean sameCustomer) throws Exception {
f.source(1,"101",501,"ONT8",3);f.source(2,sameCustomer?"101":"202",502,"LAX9",5);
Sources() throws Exception {
f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5);
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)");
f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)");
@@ -123,9 +60,9 @@ class DispatchWholeSourcePersistenceTest {
f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount());
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId());
}
var plans=mock(DispatchCandidateMapper.class);candidateMapper=plans;
var plans=mock(DispatchCandidateMapper.class);
when(plans.findCustomerScopesByPlanPalletIds(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return List.of(f.candidates.get(ids.get(0)).customerId());});
when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(1);return ids.stream().map(f.candidates::get).toList();});
when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(1);return List.of(f.candidates.get(ids.get(0)));});
sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class));
var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper);
ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
@@ -1,59 +0,0 @@
package com.baiyue.lastleg.fleet.outsourcedbusiness;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
import com.baiyue.lastleg.fleet.outsourcedbusiness.controller.OutsourcedBusinessController;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** HTTP公开JSON精简及拒绝行为,防止仅页面隐藏不支持字段。 */
class OutsourcedBusinessContractTest {
/** 即使全局允许未知字段,本域仍明确拒绝油耗、里程及客户端归属。 */
@Test void unsupportedOperationsAreRejectedEvenWithLenientGlobalJackson() throws Exception {
var json=new ObjectMapper().findAndRegisterModules().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
String body="{\"recordVersion\":\"0\",\"idempotencyKey\":\"test-original-key\",\"operations\":{\"money\":null,\"arrivals\":[]}}";
assertNotNull(json.readValue(body,OutsourcedBusinessModels.SaveOperations.class));
for(String field:List.of("actualDistanceMiles","fuelConsumptionLitresPer100Km","actualFuelLitres","ownership","scope")) {
String extra=body.replace("\"arrivals\":[]","\"arrivals\":[],\""+field+"\":null");
assertThrows(JsonProcessingException.class,()->json.readValue(extra,OutsourcedBusinessModels.SaveOperations.class),field);
}
assertThrows(JsonProcessingException.class,()->json.readValue(body.replace("\"recordVersion\":\"0\"","\"recordVersion\":\"0\",\"scope\":\"OWNED\""),OutsourcedBusinessModels.SaveOperations.class));
assertThrows(JsonProcessingException.class,()->json.readValue("{\"recordVersion\":\"0\",\"idempotencyKey\":\"bind-original\",\"fileId\":\"9\",\"ownership\":\"OWNED\"}",OutsourcedBusinessModels.BindPhoto.class));
}
/** 外配输出没有油耗字段和摘要,自有原JSON字段继续存在。 */
@Test void outsourcedSerializationIsLeanAndOwnedPublicShapeRemains() throws Exception {
var json=new ObjectMapper().findAndRegisterModules();
var output=json.valueToTree(new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),List.of()));
assertEquals(2,output.size());assertTrue(output.has("money"));assertTrue(output.has("arrivals"));
assertFalse(output.has("actualDistanceMiles"));assertFalse(output.has("fuelConsumptionLitresPer100Km"));
assertEquals(1,json.valueToTree(new OutsourcedBusinessModels.PhotoSummary(5)).size());
var external=json.valueToTree(new OutsourcedBusinessModels.Vehicle(null,"TEST-EXT","DISPATCHED",null));
assertTrue(external.get("vehicleId").isNull());assertTrue(external.get("carrierName").isNull());assertEquals(4,external.size());
var owned=json.valueToTree(new OwnedBusinessModels.Operations(null,null,null,List.of()));
assertTrue(owned.has("actualDistanceMiles"));assertTrue(owned.has("fuelConsumptionLitresPer100Km"));
assertEquals(3,json.valueToTree(new OwnedBusinessModels.Vehicle("7","TEST-OWN","OWNED")).size());
}
/** 油耗框不是外配合法URL枚举,不能先上传再在页面隐藏。 */
@Test void onlyFiveLoadingPhotoSlotsCanDeserialize() throws Exception {
var json=new ObjectMapper();assertEquals(5,OutsourcedBusinessModels.PhotoSlot.values().length);
for(var slot:OutsourcedBusinessModels.PhotoSlot.values())assertEquals(slot,json.readValue("\""+slot.name()+"\"",OutsourcedBusinessModels.PhotoSlot.class));
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_START\"",OutsourcedBusinessModels.PhotoSlot.class));
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_END\"",OutsourcedBusinessModels.PhotoSlot.class));
}
/** 八类HTTP操作各用外配真实权限,不将外配权限伪装为自有。 */
@Test void controllerAlwaysRequiresTheActualOutsourcedPermission() {
int count=0;
for(var method:OutsourcedBusinessController.class.getDeclaredMethods()) {
var authorization=method.getAnnotation(PreAuthorize.class);if(authorization==null)continue;count++;
String action=switch(method.getName()){case "save"->"edit";case "upload","bind"->"upload";default->"list";};
assertEquals("hasAuthority('platform:fleet:outsourced-business:"+action+"')",authorization.value());
assertTrue(Arrays.stream(method.getParameterTypes()).noneMatch(p->p.getSimpleName().equals("OwnedBusinessScope")));
}
assertEquals(8,count);
}
}
@@ -1,40 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 全车请求键唯一,原操作者、action、版本、载荷缺一不可。 */
class OwnedBusinessCommandTest {
/** 已有键不能按另一个operator维度重新执行或读取结果。 */
@Test void anotherActorCannotBorrowOriginalKey() {
var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec());
var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED");
when(repo.command(1,"original-key")).thenReturn(row);
var denied=assertThrows(ResponseStatusException.class,()->service.existing(1,"original-key",new Actor("other","其他测试账号",Set.of())));
assertEquals(403,denied.getStatusCode().value());verify(repo,never()).command(any());
}
/** 同一请求原版本0合法,改载荷或改版本不得借用原成功。 */
@Test void fullOriginalIdentityIsRequiredForReplay() {
var service=new OwnedBusinessCommandService(mock(OwnedBusinessRepository.class),OwnedBusinessFixtures.codec());
var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED");
assertDoesNotThrow(()->service.same(row,"SAVE_OPERATIONS",0L,"original"));
assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",1L,"original"));
assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",0L,"changed"));
assertThrows(BizException.class,()->service.same(row,"BIND_PHOTO",0L,"original"));
}
/** 未知上传只有原状态,不创建第二个File调用意图。 */
@Test void unknownUploadReservationDoesNotBecomeNewWork() {
var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec());
var row=service.create(1,OwnedBusinessFixtures.actor(),"upload-key","UPLOAD_PHOTO",null,"original","UNKNOWN_RESULT");
when(repo.command(1,"upload-key")).thenReturn(row);
var reservation=service.reserveUpload(1,OwnedBusinessFixtures.actor(),"upload-key","original");
assertFalse(reservation.created());assertEquals("UNKNOWN_RESULT",reservation.command().getStatus());verify(repo,never()).command(any());
}
}
@@ -1,30 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
/** 本域消费者的文件归属及格式回归,不重复File提供者全量测试。 */
class OwnedBusinessFileGatewayTest {
/** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */
@Test void invalidImageSignaturesAndMimeAreRejected() {
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0])));
}
/** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */
@Test void metadataCannotCrossCustomerDispatchOrCategory() {
var good=metadata(0L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO");
assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.LOADING_SCENE,9L).fileId());
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(101L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(0L,"2","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.FUEL_START,9L));
}
/** File所有权使用原dispatch而非trip或客户,照片框不能改变类型。 */
private HttpOwnedBusinessFileGateway.Metadata metadata(Long tenant,String businessId,String type) {
return new HttpOwnedBusinessFileGateway.Metadata(9L,tenant,type,businessId,"actual.png","image/png",20L,LocalDateTime.parse("2026-10-20T07:30:00"));
}
}
@@ -1,47 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.CargoRow;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 仅本地测试夹具,不操作业务环境或生成运行样本。 */
final class OwnedBusinessFixtures {
/** 夹具容器不允许实例化。 */
private OwnedBusinessFixtures() {}
/** Jackson包含实际时间类型,避免把日期当假字符串。 */
static OwnedBusinessSupport codec(){return new OwnedBusinessSupport(new ObjectMapper().findAndRegisterModules());}
/** 平台列表、编辑、上传权限保持独立。 */
static Actor actor(){return new Actor("operator-1","TEST操作员",Set.of(LIST,EDIT,UPLOAD));}
/** 装车完成但未发车,证明不依赖DEPART历史。 */
static Source source() {
var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setStatus("ACTIVE");row.setStage("AFTER_LOADING");
row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓");
row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt());
row.setOwnedResourceMode("MASTER");row.setOwnedVehicleOwnership("OWNED");row.setOwnedVehicleId(7L);row.setOwnedVehicleRecordVersion(3L);
row.setResourcesJson("""
{"vehicle":{"mode":"MASTER","vehicleId":7,"plate":"TEST-OWN-7","carrierName":null,"vehicleStatus":"IN_SERVICE",
"capacity":{"palletSpaces":10,"maxLoadKg":1000,"maxVolumeCbm":20},"evidenceFileIds":[]},
"driver":{"mode":"MASTER","driverId":8,"name":"测试司机","phone":null,"evidence":null}}
""");
row.setLoadingConfirmationJson("""
{"manifestHash":"actual-manifest","pallets":[{"warehousePalletId":31,"palletSpaces":3,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}],
"operatorId":"picker","confirmedAt":"2026-10-20T07:30:00Z"}
""");return row;
}
/** 两个客户逻辑来源共享一个真实物理托盘,不合并客户归属。 */
static List<CargoRow> cargo() {
var first=item(11L,"101","IN-A");var second=item(12L,"102","IN-B");return List.of(first,second);
}
/** 来源事实的目的地键与正式排车提供者一致。 */
private static CargoRow item(long id,String customer,String inbound) {
var i=new CargoRow();i.setId(id);i.setDispatchOrderId(1L);i.setCustomerId(customer);i.setCustomerName("测试客户"+customer);
i.setSourceKey("SOURCE:"+inbound);i.setOrderNo("ORDER-"+inbound);i.setInboundCode(inbound);i.setWarehousePalletId(31L);i.setPalletNo("PAL-31");
i.setDestinationType("FBA");i.setDestinationWarehouseCode("ONT8");i.setDestinationWarehouseName("ONT8仓");i.setPackageCount(1);return i;
}
}
@@ -1,66 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessOwnershipService;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.*;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 归属来自真实装车时车辆,不从GET和当前主档补造历史。 */
class OwnedBusinessOwnershipTest {
/** 自有车辆无论承运何种货源,都按真实主档归属捕获。 */
@Test void capturesRealOwnedVehicleAndOriginalLoadTime() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("OWNED",3)));
var service=new OwnedBusinessOwnershipService(repo,vehicles);var at=Instant.parse("2026-10-20T07:30:00Z");
service.capture(1,7L,"MASTER","load-1",at);
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
assertEquals("OWNED",saved.getValue().getVehicleOwnership());assertEquals(3L,saved.getValue().getVehicleRecordVersion());
assertEquals(LocalDateTime.parse("2026-10-20T07:30:00"),saved.getValue().getLoadingCompletedAt());
verify(vehicles,never()).lockById(anyLong());
}
/** 临时外配原模式EXTERNAL原样保存,不伪造车辆档案。 */
@Test void externalHasNoMasterIdOrVersion() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,null,"EXTERNAL","load",Instant.parse("2026-10-20T07:30:00Z"));
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
assertEquals("EXTERNAL",saved.getValue().getResourceMode());assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership());
assertNull(saved.getValue().getVehicleRecordVersion());verifyNoInteractions(vehicles);
}
/** MASTER只是档案选择,不代表车辆自有,更不代表货源自有。 */
@Test void masterVehicleCanBeDispatchedAndMustKeepThatOwnership() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("DISPATCHED",4)));
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"));
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership());assertEquals("MASTER",saved.getValue().getResourceMode());
assertEquals(4L,saved.getValue().getVehicleRecordVersion());
}
/** 重放沿原证据,不读取后来可能变化的车辆归属。 */
@Test void matchingCaptureReplaysWithoutReadingCurrentVehicle() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);var owned=new Ownership();
owned.setDispatchId(1L);owned.setVehicleId(7L);owned.setResourceMode("MASTER");owned.setVehicleOwnership("OWNED");
owned.setLoadingCompletionHash("load");owned.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
when(repo.ownership(1)).thenReturn(owned);
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"));
verifyNoInteractions(vehicles);verify(repo,never()).capture(any());
assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","different",Instant.parse("2026-10-20T07:30:00Z")));
}
/** MASTER没有真实车辆时阻断原装车事务,不退回外配。 */
@Test void rejectsMissingVehicleInsteadOfInventingOwnership() {
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.empty());
assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z")));
verify(repo,never()).capture(any());
}
/** 真实车辆快照由端口返回,测试不依赖车辆存储实现。 */
private VehicleRepository.VehicleSnapshot vehicle(String ownership,long version) {
return new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US",ownership,"IN_SERVICE",null,version,null,LocalDateTime.parse("2026-10-20T07:30:00"),10,null,null);
}
}
@@ -1,230 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.common.core.exception.BizException;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import java.time.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 本机H2/MyBatis真实四表回归,不调用环境或复用外部数据库。 */
class OwnedBusinessPersistenceTest {
JdbcTemplate jdbc;
OwnedBusinessRepository repository;
OwnedBusinessSupport codec;
OwnedBusinessProjection projection;
OwnedBusinessRecordService service;
OwnedBusinessQueryService queries;
OwnedBusinessCommandService commands;
DataSourceTransactionManager manager;
/** 精确读取根新增4表基线,测试只适配H2方言,不重写业务列或约束。 */
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:owned-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds);
jdbc.execute("""
CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32),
stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3),departed_at DATETIME(3),delivery_completed_at DATETIME(3),
loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT,
vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128))
""");
jdbc.execute("""
CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),
customer_name VARCHAR(128),source_key VARCHAR(128),order_no VARCHAR(128),inbound_code VARCHAR(128),
pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,destination_type VARCHAR(32),
destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128),
delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT)
""");
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,customer_id VARCHAR(64),batch_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(64),confirmation_no VARCHAR(64),appointment_start_utc DATETIME(3))");
jdbc.execute("""
CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(32),
operator_id VARCHAR(64),reason VARCHAR(1000),snapshot_json LONGTEXT,occurred_at DATETIME(3))
""");
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String suffix:List.of("ownership","record","command","history")) {
var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema);
assertTrue(matcher.find(),"缺少根维护的"+suffix+"表");
String ddl=matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";");
jdbc.execute(ddl);
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.addMapper(OwnedBusinessMapper.class);config.addMapper(OwnedBusinessQueryMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
repository=new OwnedBusinessRepository(session.getMapper(OwnedBusinessMapper.class),session.getMapper(OwnedBusinessQueryMapper.class));
codec=OwnedBusinessFixtures.codec();projection=new OwnedBusinessProjection(codec);
commands=new OwnedBusinessCommandService(repository,codec);queries=mock(OwnedBusinessQueryService.class);
var source=OwnedBusinessFixtures.source();
when(queries.requireOwned("DS-OWN-ONE")).thenReturn(source);
when(queries.detailForWrite(any(),any())).thenAnswer(call->new Detail(
projection.row(source,repository.record(1),repository.cargo(List.of(1L)),repository.batches(List.of(1L)),call.getArgument(0)),
null,List.of(),projection.photos(repository.record(1))));
service=tx(new OwnedBusinessRecordService(repository,queries,projection,commands,codec));
jdbc.update("""
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,status,stage,record_version,planned_departure_at,
loading_completed_at,loading_completion_hash,loading_confirmation_json,resources_json,vehicle_plate,
overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name)
VALUES(1,'DS-OWN-ONE',2,'ACTIVE','AFTER_LOADING',8,?,?,?,?,?,'TEST-OWN-7','9','WH-9','测试仓')
""",source.getPlannedDepartureAt(),source.getLoadingCompletedAt(),source.getLoadingCompletionHash(),source.getLoadingConfirmationJson(),source.getResourcesJson());
for(var item:OwnedBusinessFixtures.cargo())jdbc.update("""
INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,customer_name,source_key,order_no,inbound_code,
pallet_no,warehouse_pallet_id,destination_type,destination_warehouse_code,destination_warehouse_name,package_count)
VALUES(?,1,?,?,?,?,?,?,31,'FBA','ONT8','ONT8仓',1)
""",item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getOrderNo(),item.getInboundCode(),item.getPalletNo());
jdbc.update("INSERT INTO ll_dispatch_history VALUES(99,1,8,'LOADING_COMPLETE','picker',NULL,'{}',?)",source.getLoadingCompletedAt());
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(1L);ownership.setVehicleId(7L);
ownership.setResourceMode("MASTER");ownership.setVehicleOwnership("OWNED");ownership.setVehicleRecordVersion(3L);
ownership.setOwnershipCapturedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletionHash("actual-load");
repository.capture(ownership);
}
/** 明确NULL更新真实落库,原主单版本、货物和两个客户完全不改。 */
@Test void savesThenClearsNullableValuesWithRealMapperCas() {
var first=request("save-money-first",0,new Money("CNY","30"),"12.5","18.6",List.of(new Arrival("FBA:ONT8","2026-10-20T16:00:00+08:00")));
var saved=service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first);
assertEquals("1",saved.row().recordVersion());assertEquals("30.00",saved.row().unitPrice());
assertEquals("18.600",repository.record(1).getFuelConsumptionLitresPer100Km().toPlainString());
assertEquals("2026-10-20T08:00:00Z",codec.list(repository.record(1).getArrivalsJson(),Arrival.class).get(0).actualArrivedAt());
service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("clear-money-now",1,null,null,null,List.of()));
var cleared=repository.record(1);assertNull(cleared.getCurrency());assertNull(cleared.getTotalAmount());
assertNull(cleared.getActualDistanceMiles());assertNull(cleared.getFuelConsumptionLitresPer100Km());assertEquals(2L,cleared.getRecordVersion());
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
}
/** 真实基线CHECK必须拒绝SQL三值逻辑的UNKNOWN漏洞,同时保留未登记和明确零。 */
@Test void moneyCheckRejectsNullCurrencyWithAmountAndAcceptsNullPairAndZero() {
var actor=OwnedBusinessFixtures.actor();
service.save(actor,"DS-OWN-ONE",request("money-null-pair",0,null,null,null,List.of()));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
()->jdbc.update("UPDATE ll_fleet_owned_business_record SET currency=NULL,total_amount=1 WHERE dispatch_id=1"));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
service.save(actor,"DS-OWN-ONE",request("money-zero-value",1,new Money("CNY","0"),null,null,List.of()));
assertEquals("CNY",repository.record(1).getCurrency());assertEquals(0,repository.record(1).getTotalAmount().signum());
service.save(actor,"DS-OWN-ONE",request("money-clear-again",2,null,null,null,List.of()));
assertNull(repository.record(1).getCurrency());assertNull(repository.record(1).getTotalAmount());
}
/** 原成功载荷重放返回原版本,不能用新版本或不同操作者另起命令。 */
@Test void replayAndStaleVersionDoNotCreateExtraRecordsOrHistory() {
var first=request("first-operation",0,new Money("USD","0"),null,null,List.of());
assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion());
assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion());
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("stale-operation",0,null,null,null,List.of())));
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("first-operation",0,new Money("USD","1"),null,null,List.of())));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
}
/** 非本车目的地和不规范单位输入不能产生附加行或命令。 */
@Test void rejectsForeignDestinationBeforeAnyWrite() {
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",
request("foreign-arrival",0,null,null,null,List.of(new Arrival("FBA:OTHER","2026-10-20T08:00:00Z")))));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
}
/** 真绑定CAS、原键重放及替换后的历史引用均沿真实4表,不改变装车事实。 */
@Test void photoBindingKeepsCurrentAndHistoricalAuthorizationSeparate() {
var first=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","9","TEST-one.png","image/png","9","2026-10-20T07:30:00",null);
var original=new BindPhoto("0","bind-original","9");
var bound=service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first);
assertEquals("1",bound.row().recordVersion());assertNotNull(bound.photos().get(0).savedAt());
assertEquals("保存装车现场照片",repository.revision(1,1).getReason());
assertEquals("1",service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first).row().recordVersion());
var replacement=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","10","TEST-two.png","image/png","9","2026-10-20T07:31:00",null);
service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10"),replacement);
assertEquals("10",projection.photos(repository.record(1)).get(0).fileId());
assertEquals("9",codec.read(repository.revision(1,1).getSnapshotJson(),Snapshot.class).photos().get(0).fileId());
assertEquals("保存装车现场照片",repository.revision(1,2).getReason());
var file=mock(com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway.class);
when(file.content(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(new byte[]{1,2});
var photoService=new OwnedBusinessPhotoService(queries,service,repository,commands,projection,codec,file);
assertThrows(BizException.class,()->photoService.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,9,null));
assertArrayEquals(new byte[]{1,2},photoService.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,9,1L).content());
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
}
/** 原流水与附加流水用一次真实SQL分页,版本域不混用。 */
@Test void historyMergesOriginalDispatchWithoutDuplicatingIt() {
service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("history-register",0,null,null,"0",List.of()));
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchDetailReader.class));
var page=realQueries.history(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",1,20);
assertEquals(2,page.total());assertEquals(2,page.records().size());
var original=page.records().stream().filter(r->r.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
assertEquals("8",original.dispatchRecordVersion());assertNull(original.recordVersion());assertNull(original.operations());assertEquals(List.of(),original.photos());
var added=page.records().stream().filter(r->r.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
assertEquals("1",added.recordVersion());assertNull(added.dispatchRecordVersion());
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history",Integer.class));
}
/** 当前页不是统计范围,装车后无DEPART也能入选;GET不建附加记录。 */
@Test void listUsesFilteredSourceAndLeavesVirtualZeroUnwritten() {
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchDetailReader.class));
var list=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T00:00:00Z","2026-10-21T00:00:00Z","ONT8",null,"9"));
assertEquals(1,list.total());assertEquals(1,list.summary().afterLoadingCount());assertEquals(1,list.summary().dispatchCount());
assertEquals("0",list.records().get(0).recordVersion());assertEquals(1,list.records().get(0).actualPalletCount());
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
var empty=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T08:00:00Z","2026-10-20T08:00:01Z",null,null,null));
assertEquals(1,empty.total());
var excluded=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,null,"2026-10-20T08:00:00Z",null,null,null));
assertEquals(0,excluded.total());
}
/** 原车辆归属捕获与调用方事务同回滚,不留下假的历史所有权。 */
@Test void ownershipCaptureRollsBackWithParentTransaction() {
jdbc.update("DELETE FROM ll_fleet_owned_business_ownership");
var vehicles=mock(VehicleRepository.class);
when(vehicles.findById(7)).thenReturn(Optional.of(new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US","OWNED","IN_SERVICE",null,3,null,LocalDateTime.now(),10,null,null)));
var capture=tx(new OwnedBusinessOwnershipService(repository,vehicles));
assertThrows(IllegalStateException.class,()->new TransactionTemplate(manager).execute(status->{
capture.capture(1,7L,"MASTER","actual-load",Instant.parse("2026-10-20T07:30:00Z"));throw new IllegalStateException("模拟主装车回滚");
}));
assertNull(repository.ownership(1));verify(vehicles,never()).lockById(anyLong());
}
/** 两个真实数据库事务争用初始0版本,必须只有一个命令及历史成功。 */
@Test void concurrentZeroVersionWritesHaveExactlyOneWinner() throws Exception {
var pool=java.util.concurrent.Executors.newFixedThreadPool(2);
var start=new java.util.concurrent.CountDownLatch(1);
try {
var first=pool.submit(()->compete(start,"concurrent-first"));
var second=pool.submit(()->compete(start,"concurrent-second"));
start.countDown();
boolean a=first.get(10,java.util.concurrent.TimeUnit.SECONDS),b=second.get(10,java.util.concurrent.TimeUnit.SECONDS);
assertNotEquals(a,b);assertEquals(1L,repository.record(1).getRecordVersion());
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
} finally {pool.shutdownNow();}
}
/** 只把真实版本冲突判为竞争失败,SQL和基础设施异常必须让回归失败。 */
private boolean compete(java.util.concurrent.CountDownLatch start,String key) throws Exception {
if(!start.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new IllegalStateException("并发测试起点超时");
try{service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request(key,0,new Money("CNY","1"),null,null,List.of()));return true;}
catch(BizException error){if(error.getResultCode()!=com.baiyue.common.core.api.ResultCode.CONFLICT)throw error;return false;}
}
/** 基于实际数据库事务代理调用,验证的不只是实现分支。 */
@SuppressWarnings("unchecked")
private <T>T tx(T value) {
var proxy=new ProxyFactory(value);proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
}
/** 本机内存数据库用后关闭,避免全量回归积累DB_CLOSE_DELAY实例。 */
@AfterEach void closeDatabase(){if(jdbc!=null)jdbc.execute("SHUTDOWN");}
/** 每个请求保留原附加版本和明确量纲。 */
private SaveOperations request(String key,long version,Money money,String distance,String fuel,List<Arrival> arrivals) {
return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录");
}
}
@@ -1,88 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import org.junit.jupiter.api.*;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 上传未知结果和绑定失败恢复回归,不发任何环境HTTP。 */
class OwnedBusinessPhotoTest {
OwnedBusinessPhotoService service;
OwnedBusinessFileGateway files;
OwnedBusinessRecordService records;
OwnedBusinessQueryService queries;
OwnedBusinessRepository repository;
Map<String,OwnedBusinessRows.Command> ledger;
/** 用独立命令集合记录原键,文件服务全部为明确的本机mock。 */
@BeforeEach void setup() {
var codec=OwnedBusinessFixtures.codec();repository=mock(OwnedBusinessRepository.class);queries=mock(OwnedBusinessQueryService.class);
records=mock(OwnedBusinessRecordService.class);files=mock(OwnedBusinessFileGateway.class);ledger=new HashMap<>();
when(queries.requireOwned("DS-OWN-ONE")).thenReturn(OwnedBusinessFixtures.source());
when(repository.command(eq(1L),anyString())).thenAnswer(call->ledger.get(call.getArgument(1)));
doAnswer(call->{OwnedBusinessRows.Command row=call.getArgument(0);ledger.put(row.getIdempotencyKey(),row);return null;}).when(repository).command(any(OwnedBusinessRows.Command.class));
when(repository.finishUpload(any())).thenReturn(1);
service=new OwnedBusinessPhotoService(queries,records,repository,new OwnedBusinessCommandService(repository,codec),
new OwnedBusinessProjection(codec),codec,files);
}
/** 相同原上传请求只执行一次File,回执未绑定不能声称已经保存。 */
@Test void successfulUploadReplaysOriginalFileWithoutUploadingAgain() {
when(files.upload(eq(1L),eq(PhotoSlot.LOADING_SCENE),eq("operator-1"),any())).thenReturn(evidence());
var first=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png());
var replay=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png());
assertEquals(first,replay);assertEquals("9",first.fileId());assertNull(first.savedAt());
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());verifyNoInteractions(records);
}
/** File响应未知必须保留原键并停写,原请求重试不能再次上传。 */
@Test void unknownUploadStopsInsteadOfRepeatingFileWrite() {
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("模拟连接中断"));
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png()));
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png()));
var recovered=service.command(OwnedBusinessFixtures.actor(),"DS-OWN-ONE","upload-unknown");
assertEquals("UNKNOWN_RESULT",recovered.state());assertFalse(recovered.retryAllowed());assertNull(recovered.result());
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 相同键不能从装车框切换到油耗框,也不能由另一操作者复用。 */
@Test void uploadIdentityIncludesActorSlotAndOriginalBytes() {
when(files.upload(anyLong(),any(),anyString(),any())).thenReturn(evidence());
service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-identity",png());
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-identity",png()));
var other=new Actor("other","其他测试账号",Set.of(LIST,UPLOAD));
assertThrows(ResponseStatusException.class,()->service.command(other,"DS-OWN-ONE","upload-identity"));
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 绑定失败后仍消费原fileId/原绑定键,不触发新的上传。 */
@Test void failedBindingRetainsOriginalFileForExplicitSameRequestRetry() {
when(files.metadata(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(evidence());
when(records.bind(any(),eq("DS-OWN-ONE"),eq(PhotoSlot.LOADING_SCENE),any(),any()))
.thenThrow(new BizException(ResultCode.CONFLICT,"模拟附加版本冲突")).thenReturn(new Detail(null,null,List.of(),List.of()));
var request=new BindPhoto("0","bind-original","9");
assertThrows(BizException.class,()->service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request));
assertNotNull(service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request));
verify(files,never()).upload(anyLong(),any(),anyString(),any());
verify(files,times(2)).metadata(1,PhotoSlot.LOADING_SCENE,9);
}
/** 知道文件ID也不能越过当前/历史引用探测内容。 */
@Test void unboundFileIdIsDeniedBeforeCallingFile() {
assertThrows(BizException.class,()->service.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.RECEIPT,9,null));
verifyNoInteractions(files);
}
/** 只读用户不能调用上传,File意图也不能落库。 */
@Test void uploadPermissionIsIndependentFromList() {
assertThrows(ResponseStatusException.class,()->service.upload(new Actor("read-only","只读测试账号",Set.of(LIST)),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-denied",png()));
verifyNoInteractions(files);assertTrue(ledger.isEmpty());
}
/** 本机原始PNG签名用于消费者格式检查,不声称外部真实照片。 */
private MockMultipartFile png(){return new MockMultipartFile("file","TEST.png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10,0});}
/** 元数据归属在独立gateway测试核验,这里只验证命令生命周期。 */
private OwnedBusinessFileGateway.Evidence evidence(){return new OwnedBusinessFileGateway.Evidence(9,"TEST.png","image/png",9,"2026-10-20T07:30:00");}
}
@@ -1,45 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/** 金额按物理托盘计算;原装车和来源客户事实不重新归并。 */
class OwnedBusinessProjectionTest {
/** 同一实托两客户仍是一托,不按3个标准托位或2个来源除价。 */
@Test void sharedPhysicalPalletIsCountedOnceAndUnregisteredMoneyStaysNull() {
var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var source=OwnedBusinessFixtures.source();
var row=p.row(source,null,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
assertEquals(1,row.actualPalletCount());assertEquals("0",row.recordVersion());assertEquals("8",row.dispatchRecordVersion());
assertNull(row.operations().money());assertNull(row.unitPrice());assertNull(row.operations().fuelConsumptionLitresPer100Km());
assertEquals(1,row.destinations().size());assertNull(row.departedAt());assertTrue(row.actions().edit().allowed());
assertEquals(8L,source.getRecordVersion());
}
/** 明确0与未登记不同,单价只由服务端权威金额和实托计算。 */
@Test void explicitZeroAndMoneyRoundingRemainAuthoritative() {
var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var record=record();
record.setCurrency("CNY");record.setTotalAmount(BigDecimal.ZERO);
var zero=p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
assertEquals("0.00",zero.operations().money().amount());assertEquals("0.00",zero.unitPrice());
record.setTotalAmount(new BigDecimal("10.01"));
assertEquals("10.01",p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()).unitPrice());
}
/** 无实托不除零,不能用来源数或容量代替。 */
@Test void emptyPhysicalManifestNeverDividesByZero() {
var source=OwnedBusinessFixtures.source();source.setLoadingConfirmationJson("{\"manifestHash\":\"m\",\"pallets\":[],\"operatorId\":\"p\",\"confirmedAt\":\"2026-10-20T07:30:00Z\"}");
var record=record();record.setCurrency("USD");record.setTotalAmount(BigDecimal.ONE);
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
assertEquals(0,row.actualPalletCount());assertNull(row.unitPrice());assertFalse(row.sourceIssues().isEmpty());
}
/** null与0在每百公里油耗中同样保持区分,不沿用原型18.6。 */
@Test void explicitFuelRateIsNotDerivedAsTotalFuel() {
var record=record();record.setActualDistanceMiles(BigDecimal.ZERO);record.setFuelConsumptionLitresPer100Km(new BigDecimal("18.600"));
var ops=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).operations(record);
assertEquals("0",ops.actualDistanceMiles());assertEquals("18.600",ops.fuelConsumptionLitresPer100Km());
}
/** 虚拟记录和空照片保持真实空态。 */
private Record record(){var r=new Record();r.setRecordVersion(1L);r.setArrivalsJson("[]");r.setPhotosJson("[]");return r;}
}
@@ -1,73 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
class OwnedBusinessQueryTest {
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var after=new StageCount();after.setStage("AFTER_LOADING");after.setQuantity(2L);
var completed=new StageCount();completed.setStage("DELIVERED");completed.setQuantity(1L);
when(repo.summary(any())).thenReturn(List.of(after,completed));when(repo.page(any())).thenReturn(List.of(OwnedBusinessFixtures.source()));
when(repo.records(any())).thenReturn(List.of());when(repo.cargo(any())).thenReturn(OwnedBusinessFixtures.cargo());when(repo.batches(any())).thenReturn(List.of());
var result=service.list(OwnedBusinessFixtures.actor(),new Query(1,1,"2026-10-20T00:00:00+08:00","2026-10-21T00:00:00+08:00","ONT8",null,"9"));
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(3,result.summary().dispatchCount());assertEquals(2,result.summary().afterLoadingCount());
var search=ArgumentCaptor.forClass(Search.class);verify(repo).summary(search.capture());verify(repo).page(search.getValue());
assertEquals(LocalDateTime.parse("2026-10-19T16:00:00"),search.getValue().from());
assertEquals(LocalDateTime.parse("2026-10-20T16:00:00"),search.getValue().to());
verify(repo,never()).lock(any());verify(repo,never()).capture(any());verify(repo,never()).update(any(),anyLong());
}
/** 无归属快照不可用当前主档补造,自有页失败不修改原排车。 */
@Test void oldLoadedDispatchWithoutOwnershipIsNotReclassified() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var source=OwnedBusinessFixtures.source();
source.setOwnedVehicleOwnership(null);when(repo.source("DS-OWN-ONE")).thenReturn(source);
var dispatch=mock(DispatchDetailReader.class);var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE"));
verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion());
}
/** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */
@Test void stageAndFrozenOwnershipFactsAreBothRequired() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source);
source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setOwnedVehicleOwnership("OWNED");source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
}
/** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */
@Test void readOnlyActorHasOnlyReadGates() {
var actor=new Actor("reader","只读账号",Set.of(LIST));var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec())
.row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor);
assertFalse(row.actions().edit().allowed());assertFalse(row.actions().upload().allowed());
}
/** 客户Token即使携带同名权限也不成为平台运营身份。 */
@Test void customerTokenCannotClaimPlatformThroughPermissions() {
var codec=OwnedBusinessFixtures.codec();var user=new AuthenticatedUser(11L,101L,101L,1L,"客户账号","MERCHANT","WEB",Set.of(LIST,EDIT,UPLOAD));
assertThrows(ResponseStatusException.class,()->codec.actor(user));
var platform=new AuthenticatedUser(12L,0L,0L,2L,"平台账号","PLATFORM","WEB",Set.of(LIST));
assertEquals("平台账号",codec.actor(platform).operatorName());assertEquals("12",codec.actor(platform).operatorId());
}
/** 缺版本和非规范值不能默认为合法0,超长Long也不能截断。 */
@Test void versionsAndFuelRateNumbersAreStrict() {
var codec=OwnedBusinessFixtures.codec();assertEquals(0,codec.version("0"));assertEquals("0.000",codec.decimal("0",9,3));
for(String version:List.of("","-1","00","90071992547409919999"))assertThrows(BizException.class,()->codec.version(version));
for(String value:List.of("-1","18.6001","NaN","18e1","01"))assertThrows(BizException.class,()->codec.decimal(value,9,3));
assertThrows(BizException.class,()->codec.instant("2026-10-20T08:00:00"));
}
}
@@ -1,265 +0,0 @@
package com.baiyue.lastleg.fleet.ownedbusiness;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 复用既有四表测试装配,实际Mapper、事务、权限与原键一并验收。 */
class OwnedBusinessScopePersistenceTest {
final OwnedBusinessPersistenceTest baseline=new OwnedBusinessPersistenceTest();
OwnedBusinessQueryService queries;
OwnedBusinessRecordService records;
OwnedBusinessPhotoService photos;
OwnedBusinessFileGateway files;
OutsourcedBusinessService outsourced;
final Map<Long,OwnedBusinessFileGateway.Evidence> uploaded=new HashMap<>();
final byte[] png={(byte)137,80,78,71,13,10,26,10,0};
/** 只调用原测试的建库装配,不继承或重复运行其@Test。 */
@BeforeEach void setup() throws Exception {
baseline.setup();var reader=mock(DispatchDetailReader.class);
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
records=tx(new OwnedBusinessRecordService(baseline.repository,queries,baseline.projection,commands,baseline.codec));
files=mock(OwnedBusinessFileGateway.class);var sequence=new AtomicLong(900);
when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{
long id=sequence.incrementAndGet();var evidence=new OwnedBusinessFileGateway.Evidence(id,"TEST-"+id+".png","image/png",png.length,"2026-10-20T08:00:00Z");uploaded.put(id,evidence);return evidence;
});
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->uploaded.get(call.<Long>getArgument(2)));
when(files.content(anyLong(),any(),anyLong())).thenReturn(png);
photos=tx(new OwnedBusinessPhotoService(queries,records,baseline.repository,commands,baseline.projection,baseline.codec,files));
outsourced=new OutsourcedBusinessService(queries,records,photos,baseline.projection,baseline.codec);
insert(2,"MASTER","AFTER_LOADING","TEST原承运商");insert(3,"EXTERNAL","DELIVERING",null);insert(4,"MASTER","DELIVERED","TEST另一承运商");
}
/** 数据库仅在本机隔离H2,用后关闭。 */
@AfterEach void close(){baseline.closeDatabase();}
/** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */
@SuppressWarnings("unchecked") private <T>T tx(T target) {
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(baseline.manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
}
/** 两类外配身份固定原快照,实际托盘号和两个客户仍逐项保留。 */
private void insert(long id,String mode,String stage,String carrier) {
var original=OwnedBusinessFixtures.source();
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class);
var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of());
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver()));
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
baseline.jdbc.update("""
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1
""",id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id);
baseline.jdbc.update("""
INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type,
destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1
""",id*100,id,"PAL-"+(31+id),31+id);
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId());
ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L);
ownership.setLoadingCompletionHash(original.getLoadingCompletionHash());ownership.setLoadingCompletedAt(original.getLoadingCompletedAt());ownership.setOwnershipCapturedAt(original.getLoadingCompletedAt());baseline.repository.capture(ownership);
baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt());
if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id);
if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id);
}
/** 外配真实三权限,与自有集合完全不相交。 */
private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));}
/** 请求只包含金额和本车目的地到达,版本与原键逐次显式提供。 */
private OutsourcedBusinessModels.SaveOperations request(String version,String key,OwnedBusinessModels.Money money,List<OwnedBusinessModels.Arrival> arrivals) {
return new OutsourcedBusinessModels.SaveOperations(version,key,new OutsourcedBusinessModels.Operations(money,arrivals),"本机TEST承运登记");
}
/** 真实SQL统计跨页且按归属、阶段、日期、车牌和目的仓共同过滤。 */
@Test void fullSummaryAndFilterUseOneFrozenScope() {
var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,"9");var result=outsourced.list(actor(),query);
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(new OwnedBusinessModels.Summary(3,1,1,1),result.summary());
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"TEST-OUT-3",null,"9")).total());
assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"ONT8",null,"9")).total());
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,"other")).total());
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total());
for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total());
}
/** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */
@Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() {
var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3");
assertEquals("7",master.row().vehicle().vehicleId());assertEquals("TEST原承运商",master.row().vehicle().carrierName());
assertNull(external.row().vehicle().vehicleId());assertNull(external.row().vehicle().carrierName());
assertEquals("DISPATCHED",external.row().vehicle().ownership());assertEquals("0",master.row().recordVersion());
assertEquals(2,master.cargo().size());assertEquals(1,master.row().actualPalletCount());assertTrue(master.row().actions().edit().allowed());
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
baseline.jdbc.update("DELETE FROM ll_fleet_owned_business_ownership WHERE dispatch_id=2");
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));assertEquals(2,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,null)).total());assertNull(baseline.repository.ownership(2));
}
/** 实际服务权限与原归属双向隔离,不允许靠共享四表越权。 */
@Test void bothPermissionAndDispatchScopeAreRequiredOnReadWriteAndCommand() {
assertThrows(ResponseStatusException.class,()->outsourced.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
assertThrows(ResponseStatusException.class,()->queries.detail(actor(),"DS-OWN-ONE"));
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OWN-ONE"));
assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OWN-ONE",request("0","foreign-scope-save",null,List.of())));
assertThrows(BizException.class,()->outsourced.command(actor(),"DS-OWN-ONE","foreign-scope-key"));
assertThrows(BizException.class,()->photos.command(OwnedBusinessFixtures.actor(),"DS-OUT-2","foreign-scope-key"));
assertThrows(ResponseStatusException.class,()->outsourced.save(OwnedBusinessFixtures.actor(),"DS-OUT-2",request("0","wrong-permission-write",null,List.of())));
var all=new HashSet<>(actor().permissions());all.addAll(OwnedBusinessFixtures.actor().permissions());
var both=new OwnedBusinessModels.Actor("both-scopes","TEST双权限操作员",all);
assertThrows(BizException.class,()->outsourced.detail(both,"DS-OWN-ONE"));assertThrows(BizException.class,()->queries.detail(both,"DS-OUT-2"));
assertThrows(BizException.class,()->records.save(both,"DS-OUT-2",new OwnedBusinessModels.SaveOperations("0","wrong-owned-write",new OwnedBusinessModels.Operations(null,null,null,List.of()),null)));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
}
/** 多客户同一物理托盘只除一次,逐目的地登记、零值与NULL清空都走真实CAS。 */
@Test void samePhysicalPalletMultipleDestinationsAndMoneyClearUseRealCas() {
baseline.jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE id=212");
var arrivals=List.of(new OwnedBusinessModels.Arrival("FBA:ONT8","2026-10-20T08:10:00Z"),new OwnedBusinessModels.Arrival("FBA:LAX9","2026-10-20T08:20:00Z"));
var body=request("0","carrier-amount-original",new OwnedBusinessModels.Money("CNY","60"),arrivals);
var first=outsourced.save(actor(),"DS-OUT-2",body);
assertEquals("60.00",first.row().unitPrice());assertEquals(2,first.row().destinations().size());assertTrue(first.row().destinations().stream().allMatch(d->d.actualArrivedAt()!=null));
assertEquals(first,outsourced.save(actor(),"DS-OUT-2",body));
assertEquals(first,outsourced.command(actor(),"DS-OUT-2",body.idempotencyKey()).result());
var stale=assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","carrier-amount-stale",null,List.of())));assertEquals(ResultCode.CONFLICT,stale.getResultCode());
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0",body.idempotencyKey(),new OwnedBusinessModels.Money("USD","0"),arrivals)));
var zero=outsourced.save(actor(),"DS-OUT-2",request("1","carrier-amount-zero",new OwnedBusinessModels.Money("USD","0"),arrivals));
assertEquals("0.00",zero.row().unitPrice());assertEquals("USD",zero.row().operations().money().currency());
var cleared=outsourced.save(actor(),"DS-OUT-2",request("2","carrier-amount-clear",null,List.of()));
assertNull(cleared.row().operations().money());assertNull(cleared.row().unitPrice());assertTrue(cleared.row().destinations().stream().allMatch(d->d.actualArrivedAt()==null));
assertNull(baseline.repository.record(2).getCurrency());assertNull(baseline.repository.record(2).getActualDistanceMiles());assertNull(baseline.repository.record(2).getFuelConsumptionLitresPer100Km());
assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
}
/** 油耗及里程在共享服务再次拒绝,不能绕过外配DTO直接调用内部入口。 */
@Test void unsupportedFuelAndForeignDestinationAreRejectedBeforeAnyWrite() {
var body=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-write",new OwnedBusinessModels.Operations(null,"1",null,List.of()),null);
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",body,OwnedBusinessScope.OUTSOURCED));
var fuel=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-rate",new OwnedBusinessModels.Operations(null,null,"1",List.of()),null);
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",fuel,OwnedBusinessScope.OUTSOURCED));
assertThrows(BizException.class,()->photos.upload(actor(),"DS-OUT-2",OwnedBusinessModels.PhotoSlot.FUEL_START,"illegal-fuel-photo",new MockMultipartFile("file",png),OwnedBusinessScope.OUTSOURCED));
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","illegal-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:OTHER",null)))));
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","duplicate-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:ONT8",null),new OwnedBusinessModels.Arrival("FBA:ONT8",null)))));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));verifyNoInteractions(files);
}
/** 五框上传/绑定/原键恢复及历史读取保持同车原引用,异账号不能复用。 */
@Test void fivePhotosReplayHistoryAndActorIsolationDoNotChangeDispatch() {
String version="0",firstFile=null;int index=0;
for(var slot:OutsourcedBusinessModels.PhotoSlot.values()) {
var file=new MockMultipartFile("file","TEST.png","image/png",png);String uploadKey="out-upload-"+slot;
var photo=outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file);assertNull(photo.savedAt());
assertEquals(photo,outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file));
assertEquals(version,outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
var binding=new OutsourcedBusinessModels.BindPhoto(version,"out-bind-"+slot,photo.fileId());
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,binding);assertEquals(bound,outsourced.bind(actor(),"DS-OUT-2",slot,binding));version=bound.row().recordVersion();
if(index++==0)firstFile=photo.fileId();assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",slot,Long.parseLong(photo.fileId()),null).content());
}
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId()));
final long oldId=Long.parseLong(firstFile);
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
var history=outsourced.history(actor(),"DS-OUT-2",1,200);assertEquals(7,history.total());
assertTrue(history.records().stream().anyMatch(h->h.eventSource().equals("DISPATCH") && h.recordVersion()==null && h.dispatchRecordVersion().equals("8") && h.operations()==null));
assertTrue(history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).allMatch(h->h.dispatchRecordVersion()==null && h.reason().startsWith("保存")));
var other=new OwnedBusinessModels.Actor("other","TEST另一外配账号",actor().permissions());
assertThrows(ResponseStatusException.class,()->outsourced.command(other,"DS-OUT-2","out-upload-LOADING_SCENE"));
assertThrows(ResponseStatusException.class,()->outsourced.upload(other,"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-upload-LOADING_SCENE",new MockMultipartFile("file","TEST.png","image/png",png)));
verify(files,times(6)).upload(anyLong(),any(),anyString(),any());assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
}
/** File结果未知只保存原意图,任何同键重放不得再次写File。 */
@Test void unknownUploadIsRecoverableButNeverAutomaticallyRetried() {
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("本机模拟连接中断"));
var file=new MockMultipartFile("file","TEST.png","image/png",png);
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
var command=outsourced.command(actor(),"DS-OUT-2","out-upload-unknown");assertEquals("UNKNOWN_RESULT",command.state());assertFalse(command.retryAllowed());assertNull(command.result());
assertEquals("OUTSOURCED_UPLOAD_UNKNOWN_RESULT",command.errorCode());verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 原装车ID、模式、hash、时间任何一项不一致都不能进入外配详情。 */
@Test void frozenIdentityHashAndTimeCannotBeSubstituted() {
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=999 WHERE dispatch_id=2");
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=7,loading_completion_hash='different' WHERE dispatch_id=2");
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash='actual-load',loading_completed_at=? WHERE dispatch_id=2",OwnedBusinessFixtures.source().getLoadingCompletedAt().plusSeconds(1));
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
String external=baseline.repository.source("DS-OUT-3").getResourcesJson().replace("\"vehicleId\":null","\"vehicleId\":99");
baseline.jdbc.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=3",external);
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3"));
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
}
/** 承运金额按两实托HALF_UP两位,零实托不能伪造单价或使用来源条数替代。 */
@Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() {
String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},");
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load);
baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211");
var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of()));
assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice());
var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class);
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",baseline.codec.write(new DispatchContracts.ConfirmedLoad(confirmation.manifestHash(),List.of(),confirmation.operatorId(),confirmation.confirmedAt())));
var empty=outsourced.detail(actor(),"DS-OUT-2");assertEquals(0,empty.row().actualPalletCount());assertNull(empty.row().unitPrice());assertFalse(empty.row().sourceIssues().isEmpty());
}
/** 元数据暂不可读时原绑定体和fileId不丢失,恢复后不重复上传。 */
@Test void failedBindRetainsOriginalFileAndKeyForExplicitRecovery() {
var slot=OutsourcedBusinessModels.PhotoSlot.LOADING_LIST;
var uploadedPhoto=outsourced.upload(actor(),"DS-OUT-2",slot,"out-bind-failure-upload",new MockMultipartFile("file","TEST.png","image/png",png));
long fileId=Long.parseLong(uploadedPhoto.fileId());
when(files.metadata(2,OwnedBusinessModels.PhotoSlot.LOADING_LIST,fileId)).thenThrow(new BizException(ResultCode.CONFLICT,"本机模拟元数据暂不可读")).thenReturn(uploaded.get(fileId));
var body=new OutsourcedBusinessModels.BindPhoto("0","out-bind-failure-key",uploadedPhoto.fileId());
assertThrows(BizException.class,()->outsourced.bind(actor(),"DS-OUT-2",slot,body));
assertEquals("0",outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,body);assertEquals(uploadedPhoto.fileId(),bound.photos().get(0).fileId());
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
}
/** 完整摘要严格匹配,同前缀末位、大小写和尾空格均不能误命中。 */
@Test void queryHashComparesCompleteValueRatherThanDefaultBinaryWidth() {
String prefix="a".repeat(63),exact=prefix+"1";
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_completion_hash=? WHERE id=1",exact);
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null);
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
for(String different:List.of(prefix+"2","A"+exact.substring(1),prefix+" ")) {
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",different);
var result=queries.list(OwnedBusinessFixtures.actor(),query);assertEquals(0,result.total());assertTrue(result.records().isEmpty());
}
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
}
/** 历史投影统一字符比较规则,完整保留中文、原尾空格、动作和原操作账号。 */
@Test void historyCharacterProjectionPreservesOriginalTextAndSnapshot() {
// 原测试装配的原生历史采用较短替身列,这里只在隔离H2对齐实际原表上限。
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN operator_id VARCHAR(128)");
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN reason VARCHAR(2000)");
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN action VARCHAR(40)");
String nativeReason="原排车完整中文历史🌏"+"中".repeat(1200)+" ",operationReason="整车承运登记完整中文说明🌏 ",operator="o".repeat(127)+"X",nativeAction="A".repeat(39)+"X";
baseline.jdbc.update("UPDATE ll_dispatch_history SET reason=?,operator_id=?,action=? WHERE dispatch_id=2",nativeReason,operator,nativeAction);
var saved=outsourced.save(actor(),"DS-OUT-2",new OutsourcedBusinessModels.SaveOperations("0","history-character-original",
new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("CNY","30"),List.of()),operationReason));
var history=outsourced.history(actor(),"DS-OUT-2",1,20);assertEquals(2,history.total());
var original=history.records().stream().filter(h->h.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
var operations=history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
assertEquals(nativeReason,original.reason());assertEquals(nativeAction,original.action());
assertEquals(operationReason,operations.reason());assertEquals("SAVE_OPERATIONS",operations.action());
assertEquals(saved.row().operations(),operations.operations());assertEquals("TEST外配操作员",operations.operatorName());
var raw=baseline.repository.history(2,0,20).stream().filter(h->h.getEventSource().equals("DISPATCH")).findFirst().orElseThrow();
assertEquals(operator,raw.getOperatorId());
}
}
@@ -12,23 +12,20 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripAuthorizationTest {
/** 日历按现行排车只读权限放行,预约和旧车次权限不能替代。 */
@Test void calendarRequiresDispatchReadPermissionThroughMethodSecurity() {
@Test void calendarRequiresTripReadPermissionThroughMethodSecurity() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformFleetTripController.class);
var calendar = context.getBean(FleetTripCalendarService.class);
var from = java.time.Instant.parse("2026-10-01T00:00:00Z");
var to = from.plusSeconds(86400);
var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L,
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:overseas-warehouse:dispatch:list"));
for (var permission : List.of("platform:appointment:list", "platform:fleet:trip:list")) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority(permission))));
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
}
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:fleet:trip:list"));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:appointment:list"))));
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(calendar);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:list"))));
user, "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
controller.calendar(user, from, to);
verify(calendar).calendar(from, to);
} finally { SecurityContextHolder.clearContext(); }
@@ -164,46 +164,6 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
/** 实际SHIP后报废的原托扫码仍拒绝;保留提供者业务码/原因,不伪装为服务故障或按ID重试。 */
@Test
void inspectPreservesScrappedPalletBusinessConflictWithoutFallback() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/D1747-1-001"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘已报废","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.inspectPallet("D1747-1-001"))
.isInstanceOfSatisfying(ResponseStatusException.class, error -> {
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(error.getReason()).isEqualTo("托盘已报废");
});
server.verify();
}
/** 按ID读取使用同一响应边界,错误码即使带data也不能被当作合法实托。 */
@Test
void idLookupRejectsBusinessConflictEvenWithPayload() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘状态冲突","data":{"palletId":31,"palletCode":"PAL-1",
"warehouseId":88,"locationId":40,"status":"IDLE","version":1}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet(31L))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
/** 同一个状态冲突无论通过HTTP状态还是业务信封传输,都不能降为400或503。 */
@Test
void inspectPreservesHttpConflict() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-CONFLICT"))
.andRespond(withStatus(HttpStatus.CONFLICT));
assertThatThrownBy(() -> gateway.inspectPallet("PAL-CONFLICT"))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
@Test
void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001"))

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