Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a43c4d3321 | ||
|
|
2ec4627db6 | ||
|
|
c324de9c32 | ||
|
|
9f3755f8e2 | ||
|
|
8e9cbf6aa2 |
@@ -4437,6 +4437,20 @@ CREATE TABLE fl_inbound_split_item (
|
||||
|
||||
SET SESSION foreign_key_checks = 1;
|
||||
|
||||
-- 客户独立确认计费重,不回填既有费用确认。
|
||||
CREATE TABLE fl_chargeable_weight_confirmation (
|
||||
id BIGINT NOT NULL COMMENT '确认记录ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
|
||||
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
|
||||
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
|
||||
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
|
||||
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
|
||||
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
|
||||
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
|
||||
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
|
||||
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
|
||||
|
||||
-- 服务 schema:baiyue_file
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_file;
|
||||
@@ -6848,6 +6862,12 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
@@ -8377,4 +8397,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables;
|
||||
|
||||
@@ -1656,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history (
|
||||
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
|
||||
CREATE TABLE ll_order (
|
||||
id bigint NOT NULL COMMENT '雪花 ID',
|
||||
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
|
||||
@@ -1865,12 +1871,6 @@ CREATE TABLE ll_order_no_daily_sequence (
|
||||
PRIMARY KEY (business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
|
||||
|
||||
CREATE TABLE ll_dispatch_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
|
||||
CREATE TABLE ll_order_operation_log (
|
||||
id bigint NOT NULL COMMENT '操作记录主键',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
|
||||
@@ -4888,7 +4888,6 @@ CREATE TABLE ll_fleet_pickup_history (
|
||||
KEY idx_pickup_history_trip(trip_no,id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||
|
||||
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
|
||||
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
|
||||
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
||||
@@ -4968,4 +4967,4 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
|
||||
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables;
|
||||
|
||||
@@ -101,6 +101,9 @@ VALUES
|
||||
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
|
||||
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
|
||||
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
|
||||
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
|
||||
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
|
||||
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0),
|
||||
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
|
||||
(5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0),
|
||||
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
|
||||
@@ -164,7 +167,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
|
||||
SELECT 0, role.id, permission.id, NOW(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
|
||||
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */
|
||||
class FleetBusinessPermissionSeedSqlContractTest {
|
||||
/** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */
|
||||
@Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception {
|
||||
String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
|
||||
String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
for(String sql:List.of(local,unified)) {
|
||||
for(var scope:List.of("owned-business","outsourced-business")) {
|
||||
long base=scope.equals("owned-business")?5107390L:5107400L;
|
||||
assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'");
|
||||
assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'");
|
||||
assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'");
|
||||
assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'");
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */
|
||||
@Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception {
|
||||
String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
|
||||
var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
|
||||
.map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList();
|
||||
assertThat(grants).hasSize(1);
|
||||
assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'",
|
||||
"role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0");
|
||||
}
|
||||
/** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */
|
||||
@Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception {
|
||||
String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
for(long id:List.of(5107400L,5107401L,5107402L)) {
|
||||
var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
|
||||
.map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList();
|
||||
assertThat(grants).hasSize(1);
|
||||
assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const ownedBusinessReadEdges = [
|
||||
['service.OwnedBusinessQueryService', 'DispatchTypes'],
|
||||
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
|
||||
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
|
||||
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
|
||||
|
||||
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
|
||||
const report = analyze();
|
||||
@@ -55,7 +56,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
||||
}
|
||||
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
|
||||
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
|
||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
|
||||
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||
assert.ok(dispatchEdges.length > 0);
|
||||
@@ -92,6 +93,21 @@ test('owned business reads only reviewed dispatch ports and cannot inherit persi
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
|
||||
});
|
||||
|
||||
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
|
||||
const report = analyze();
|
||||
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
|
||||
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
|
||||
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
|
||||
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
|
||||
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
|
||||
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
|
||||
report.classDependencies.push(edge);
|
||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
|
||||
}
|
||||
});
|
||||
|
||||
function analyze(sourceSet = 'main') {
|
||||
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
||||
cwd: moduleRoot,
|
||||
|
||||
+2
-1
@@ -261,7 +261,8 @@ public interface DispatchCandidateMapper {
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||
"AND pos.hold_packages > 0 " +
|
||||
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
|
||||
"AND pos.hold_packages > 0 AND pos.pallet_id > 0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||
"ON existing_dispatch.id=di.dispatch_order_id " +
|
||||
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
|
||||
@Mapper
|
||||
public interface DispatchNumberSequenceMapper {
|
||||
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
|
||||
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
|
||||
int allocate(@Param("date") LocalDate date);
|
||||
|
||||
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
|
||||
@Select("SELECT LAST_INSERT_ID()")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
Long allocatedValue();
|
||||
}
|
||||
+6
-6
@@ -2,7 +2,7 @@ package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -11,17 +11,17 @@ import java.time.LocalDate;
|
||||
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
|
||||
@Repository
|
||||
public class DispatchNumberSequence {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final DispatchNumberSequenceMapper mapper;
|
||||
|
||||
/** 流水与排车主单使用相同服务数据库。 */
|
||||
public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long next(LocalDate date) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date);
|
||||
Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
|
||||
mapper.allocate(date);
|
||||
Long value = mapper.allocatedValue();
|
||||
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
|
||||
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
|
||||
return value;
|
||||
|
||||
+3
-1
@@ -90,7 +90,9 @@ public class DispatchService {
|
||||
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
|
||||
reasons.addAll(facts.blockReasons());
|
||||
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
|
||||
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
|
||||
.filter(id->id!=null && id>0).forEach(pallets::add);
|
||||
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
|
||||
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
|
||||
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business")
|
||||
public class OutsourcedBusinessController {
|
||||
private final OutsourcedBusinessService service;
|
||||
private final OwnedBusinessSupport access;
|
||||
/** 会话只转真实平台操作者及其原权限集合。 */
|
||||
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
|
||||
/** 同筛选分页和完整阶段统计一次返回。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
|
||||
/** 列表权限包含详情,不借原排车或自有写权限。 */
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
|
||||
/** 原主单和本页附加历史保持各自版本字段。 */
|
||||
@GetMapping("/{dispatchNo}/history")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
|
||||
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
|
||||
}
|
||||
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
|
||||
@PutMapping("/{dispatchNo}/operations")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
|
||||
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
|
||||
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
|
||||
}
|
||||
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
|
||||
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
|
||||
@PutMapping("/{dispatchNo}/photos/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
|
||||
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
|
||||
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(result.content());
|
||||
}
|
||||
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
|
||||
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
|
||||
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
|
||||
public final class OutsourcedBusinessModels {
|
||||
/** DTO容器不允许实例化。 */
|
||||
private OutsourcedBusinessModels() {}
|
||||
public static final String LIST="platform:fleet:outsourced-business:list";
|
||||
public static final String EDIT="platform:fleet:outsourced-business:edit";
|
||||
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
|
||||
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
|
||||
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
|
||||
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
|
||||
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
|
||||
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
|
||||
public record PhotoSummary(int loadingCount) {}
|
||||
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
|
||||
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
|
||||
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
|
||||
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
|
||||
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
|
||||
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
|
||||
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
|
||||
public record HistoryPage(List<History> records,int page,int size,int total) {}
|
||||
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
|
||||
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
|
||||
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
|
||||
|
||||
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
|
||||
@Service
|
||||
public class OutsourcedBusinessService {
|
||||
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
|
||||
private final OwnedBusinessQueryService queries;
|
||||
private final OwnedBusinessRecordService records;
|
||||
private final OwnedBusinessPhotoService photos;
|
||||
private final OwnedBusinessProjection projection;
|
||||
private final OwnedBusinessSupport codec;
|
||||
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
|
||||
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
|
||||
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
|
||||
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
|
||||
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
|
||||
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
|
||||
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
|
||||
}
|
||||
/** 三个页签读取同一原排车详情和外配附加记录。 */
|
||||
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
|
||||
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
|
||||
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
|
||||
var result=queries.history(actor,number,page,size,SCOPE);
|
||||
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
|
||||
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
|
||||
}
|
||||
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
|
||||
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
|
||||
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
var ops=request.operations();
|
||||
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
|
||||
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
|
||||
}
|
||||
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
|
||||
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
|
||||
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
|
||||
}
|
||||
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
|
||||
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
if(request==null)throw bad("照片绑定请求缺失");
|
||||
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
|
||||
}
|
||||
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
|
||||
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
|
||||
}
|
||||
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
|
||||
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
|
||||
var original=photos.command(actor,number,key,SCOPE);Object result=null;
|
||||
if(original.result()!=null)result=switch(original.action()) {
|
||||
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
|
||||
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
|
||||
default->throw conflict("原命令类型不属于外配业务");
|
||||
};
|
||||
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
|
||||
}
|
||||
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
|
||||
private Detail detail(OwnedBusinessModels.Detail detail) {
|
||||
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
|
||||
}
|
||||
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
|
||||
private Row row(OwnedBusinessModels.Row r,String carrierName) {
|
||||
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
|
||||
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
|
||||
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
|
||||
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
|
||||
}
|
||||
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
|
||||
private Operations operations(OwnedBusinessModels.Operations ops) {
|
||||
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
|
||||
return new Operations(ops.money(),ops.arrivals());
|
||||
}
|
||||
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
|
||||
private Photo photo(OwnedBusinessModels.Photo p) {
|
||||
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
|
||||
}
|
||||
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
|
||||
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
|
||||
}
|
||||
+9
-3
@@ -68,14 +68,20 @@ public interface OwnedBusinessMapper {
|
||||
#{reason},#{snapshotJson},#{occurredAt})
|
||||
""")
|
||||
int insertHistory(History row);
|
||||
/** 历史按本页版本顺序分页。 */
|
||||
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
|
||||
@Select("""
|
||||
SELECT * FROM (
|
||||
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||
UNION ALL
|
||||
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
|
||||
""")
|
||||
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
|
||||
|
||||
+5
-1
@@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper {
|
||||
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
|
||||
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
|
||||
""";
|
||||
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
|
||||
String FILTER="""
|
||||
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
|
||||
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
|
||||
AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
|
||||
AND o.vehicle_ownership=#{q.scope.ownership}
|
||||
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
|
||||
AND o.loading_completed_at=d.loading_completed_at
|
||||
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
|
||||
<if test='q.from != null'> AND d.planned_departure_at >= #{q.from}</if>
|
||||
<if test='q.to != null'> AND d.planned_departure_at < #{q.to}</if>
|
||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||
|
||||
+1
-1
@@ -52,6 +52,6 @@ public final class OwnedBusinessRows {
|
||||
private Long allocatedPackages;
|
||||
}
|
||||
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
|
||||
LocalDateTime from,LocalDateTime to) {}
|
||||
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
|
||||
@Data public static class StageCount { private String stage;private Long quantity; }
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.model;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
|
||||
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
|
||||
public enum OwnedBusinessScope {
|
||||
OWNED("OWNED","platform:fleet:owned-business:"),
|
||||
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
|
||||
private final String ownership,prefix;
|
||||
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
|
||||
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
|
||||
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
|
||||
public String getOwnership(){return ownership;}
|
||||
/** 查看权限包含本作用域详情、历史和照片读取。 */
|
||||
public String listPermission(){return prefix+"list";}
|
||||
/** 运营写权限独立,外配权限不映射成自有权限。 */
|
||||
public String editPermission(){return prefix+"edit";}
|
||||
/** 照片写权限独立,不扩大到原排车。 */
|
||||
public String uploadPermission(){return prefix+"upload";}
|
||||
/** 外配只支持装车五框,服务调用同样不能绕过。 */
|
||||
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
|
||||
/** 不存在只显示当前业务归属,不泄露内部身份。 */
|
||||
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
|
||||
}
|
||||
+31
-10
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
@@ -32,7 +33,12 @@ public class OwnedBusinessPhotoService {
|
||||
public record Download(Photo photo,byte[] content) {}
|
||||
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
|
||||
codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number);
|
||||
return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||
if(slot==null || multipart==null)throw bad("请选择照片及分类");
|
||||
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
|
||||
@@ -49,41 +55,56 @@ public class OwnedBusinessPhotoService {
|
||||
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
|
||||
} catch(RuntimeException failure) {
|
||||
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");}
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");}
|
||||
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
|
||||
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
|
||||
}
|
||||
}
|
||||
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失");
|
||||
return bind(actor,number,slot,request,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot);
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
|
||||
if(fileId<=0)throw bad("文件编号无效");
|
||||
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(previous!=null) {
|
||||
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
|
||||
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
|
||||
return codec.read(previous.getResultJson(),Detail.class);
|
||||
}
|
||||
return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId)));
|
||||
var verified=photo(slot,files.metadata(source.getId(),slot,fileId));
|
||||
return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope);
|
||||
}
|
||||
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||
codec.require(actor,LIST);var source=queries.requireOwned(number);List<Photo> bound;
|
||||
return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 五框历史授权仍由同车同版本的真实引用限定。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List<Photo> bound;
|
||||
if(revision==null)bound=projection.photos(repository.record(source.getId()));
|
||||
else {
|
||||
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
|
||||
if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||
if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||
}
|
||||
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing);
|
||||
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing);
|
||||
return new Download(photo,files.content(source.getId(),slot,fileId));
|
||||
}
|
||||
/** 恢复先校验原操作者,不披露他人载荷。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Command command(Actor actor,String number,String key) {
|
||||
codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor);
|
||||
if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||
return command(actor,number,key,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor);
|
||||
if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
|
||||
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
|
||||
}
|
||||
|
||||
+16
-2
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -45,9 +46,15 @@ public class OwnedBusinessProjection {
|
||||
}
|
||||
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
|
||||
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
|
||||
return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */
|
||||
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
|
||||
var resources=codec.read(source.getResourcesJson(),Resources.class);
|
||||
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
|
||||
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|
||||
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
|
||||
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
|
||||
var destinations=new LinkedHashMap<String,Destination>();
|
||||
for(var item:cargo) {
|
||||
@@ -61,11 +68,18 @@ public class OwnedBusinessProjection {
|
||||
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
|
||||
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
|
||||
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
|
||||
new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"),
|
||||
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
|
||||
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
|
||||
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
|
||||
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
|
||||
new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||
}
|
||||
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
|
||||
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
|
||||
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
|
||||
public String carrierName(Detail detail) {
|
||||
return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null
|
||||
?null:detail.dispatch().vehicleInfo().vehicle().carrierName();
|
||||
}
|
||||
/** 附加权限独立于原排车写动作。 */
|
||||
private Gate gate(Actor actor,String permission) {
|
||||
|
||||
+44
-14
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
@@ -29,12 +30,19 @@ public class OwnedBusinessQueryService {
|
||||
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public ListResult list(Actor actor,Query query) {
|
||||
codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失");
|
||||
return listScoped(actor,query,OwnedBusinessScope.OWNED).result();
|
||||
}
|
||||
/** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */
|
||||
public record ScopedListResult(ListResult result,Map<String,String> carrierNames) {}
|
||||
/** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失");
|
||||
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
|
||||
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
|
||||
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
|
||||
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to));
|
||||
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope);
|
||||
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
|
||||
for(var c:counts) {
|
||||
int n=Math.toIntExact(c.getQuantity());
|
||||
@@ -44,18 +52,26 @@ public class OwnedBusinessQueryService {
|
||||
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
|
||||
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
|
||||
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
|
||||
var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()),
|
||||
var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()),
|
||||
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
|
||||
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList();
|
||||
return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered));
|
||||
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList();
|
||||
var carriers=new LinkedHashMap<String,String>();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source));
|
||||
return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers));
|
||||
}
|
||||
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
|
||||
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
|
||||
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
|
||||
public Detail detailForWrite(Actor actor,Source source) {
|
||||
eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor);
|
||||
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
|
||||
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
|
||||
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
|
||||
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
|
||||
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
|
||||
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
|
||||
@@ -64,23 +80,37 @@ public class OwnedBusinessQueryService {
|
||||
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
|
||||
public Source requireOwned(String number) {
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
var source=repository.source(number);if(source==null)throw missing();eligible(source);return source;
|
||||
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
|
||||
}
|
||||
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
|
||||
public Source requireScoped(String number,OwnedBusinessScope scope) {
|
||||
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source;
|
||||
}
|
||||
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
|
||||
private void eligible(Source source) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务");
|
||||
if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing();
|
||||
private void eligible(Source source,OwnedBusinessScope scope) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
|
||||
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|
||||
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
|
||||
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
|
||||
if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId())
|
||||
|| !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
|
||||
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
|
||||
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
|
||||
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
|
||||
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
|
||||
}
|
||||
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
|
||||
codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize);
|
||||
return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize);
|
||||
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
|
||||
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
|
||||
|
||||
+22
-8
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
@@ -30,9 +31,16 @@ public class OwnedBusinessRecordService {
|
||||
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,SaveOperations request) {
|
||||
codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
return save(actor,number,request,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null))
|
||||
throw bad("外配业务不支持里程或油耗登记");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
|
||||
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
|
||||
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
|
||||
var ops=normalize(source,request.operations());var row=lock(source,actor);
|
||||
@@ -42,13 +50,18 @@ public class OwnedBusinessRecordService {
|
||||
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
|
||||
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
|
||||
row.setArrivalsJson(codec.write(ops.arrivals()));
|
||||
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason());
|
||||
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope);
|
||||
}
|
||||
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number);
|
||||
return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot);
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||
Object identity=new PhotoBinding(slot,request);
|
||||
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
|
||||
@@ -60,7 +73,7 @@ public class OwnedBusinessRecordService {
|
||||
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
|
||||
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
||||
}
|
||||
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
||||
private String photoReason(PhotoSlot slot) {
|
||||
@@ -104,13 +117,14 @@ public class OwnedBusinessRecordService {
|
||||
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
|
||||
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
|
||||
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
|
||||
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) {
|
||||
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) {
|
||||
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
|
||||
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
|
||||
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
|
||||
var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||
var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope);
|
||||
commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||
}
|
||||
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
|
||||
private Detail result(OwnedBusinessRows.Command command) {
|
||||
|
||||
+6
-1
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
@@ -24,9 +25,13 @@ public class OwnedBusinessSupport {
|
||||
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
|
||||
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
|
||||
public Actor actor(AuthenticatedUser user) {
|
||||
return actor(user,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */
|
||||
public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) {
|
||||
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务");
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||
String operator=user.userId()==null?user.username():user.userId().toString();
|
||||
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
|
||||
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
|
||||
|
||||
+2
@@ -68,7 +68,9 @@ public class RelabelDispatchSourceRepository {
|
||||
if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
|
||||
if (position == null) continue;
|
||||
positions.put(position.getId(), position);
|
||||
// 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
|
||||
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
|
||||
&& position.getPalletId() > 0
|
||||
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
|
||||
long notified = number(item.getNotifiedPackageCount());
|
||||
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|
||||
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
|
||||
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
|
||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
|
||||
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
|
||||
|| position.getPalletId() <= 0) {
|
||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
|
||||
bound = false;
|
||||
}
|
||||
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
|
||||
|
||||
@@ -45,7 +45,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB COMMENT='排车单号每日独立原子流水';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order (
|
||||
id BIGINT NOT NULL COMMENT '雪花 ID',
|
||||
@@ -4560,7 +4560,6 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
|
||||
KEY idx_pickup_history_trip(trip_no,id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||
|
||||
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
|
||||
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
|
||||
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: "原排车外配车辆运营视图"
|
||||
version: "1.0.0"
|
||||
description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。"
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business:
|
||||
get:
|
||||
operationId: listOutsourcedBusiness
|
||||
summary: "外配列表及同筛选四项完整统计"
|
||||
x-permission-code: "platform:fleet:outsourced-business:list"
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: "原排车号、车牌或真实目的仓/FBA编码" }
|
||||
- { name: warehouseId, in: query, schema: { type: string, maxLength: 64 } }
|
||||
- { name: plannedDepartureFrom, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间下界(含)" }
|
||||
- { name: plannedDepartureTo, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间上界(不含)" }
|
||||
- { name: stage, in: query, schema: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } }
|
||||
responses:
|
||||
"200":
|
||||
description: "完整条件统计不带分页,不逐行GET;响应为统一ApiResponse。"
|
||||
content:
|
||||
application/json:
|
||||
schema: { "$ref": "#/components/schemas/ListResponse" }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
|
||||
get:
|
||||
operationId: getOutsourcedBusiness
|
||||
summary: "外配原排车组合详情"
|
||||
x-permission-code: "platform:fleet:outsourced-business:list"
|
||||
responses:
|
||||
"200": { "$ref": "#/components/responses/DetailResponse" }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/history:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
|
||||
get:
|
||||
operationId: getOutsourcedBusinessHistory
|
||||
summary: "原排车与运营附加历史分页"
|
||||
x-permission-code: "platform:fleet:outsourced-business:list"
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
responses:
|
||||
"200":
|
||||
description: "DISPATCH/OPERATIONS保留独立版本,原历史无虚构运营字段。"
|
||||
content:
|
||||
application/json:
|
||||
schema: { "$ref": "#/components/schemas/HistoryResponse" }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/operations:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
|
||||
put:
|
||||
operationId: saveOutsourcedBusinessOperations
|
||||
summary: "登记或清空整车承运金额和逐目的仓实际到达"
|
||||
x-permission-code: "platform:fleet:outsourced-business:edit"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { "$ref": "#/components/schemas/SaveOperations" }
|
||||
responses:
|
||||
"200": { "$ref": "#/components/responses/DetailResponse" }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/uploads:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
|
||||
post:
|
||||
operationId: uploadOutsourcedBusinessPhoto
|
||||
summary: "准备五框照片文件,尚未绑定"
|
||||
x-permission-code: "platform:fleet:outsourced-business:upload"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file, idempotencyKey]
|
||||
properties:
|
||||
file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" }
|
||||
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
|
||||
responses:
|
||||
"200":
|
||||
description: "savedAt=null;未知结果保留原键查询,不重传。"
|
||||
content:
|
||||
application/json:
|
||||
schema: { "$ref": "#/components/schemas/PhotoResponse" }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
|
||||
put:
|
||||
operationId: bindOutsourcedBusinessPhoto
|
||||
summary: "以独立稳定键绑定原File编号"
|
||||
x-permission-code: "platform:fleet:outsourced-business:upload"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { "$ref": "#/components/schemas/BindPhoto" }
|
||||
responses:
|
||||
"200": { "$ref": "#/components/responses/DetailResponse" }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/files/{fileId}/content:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ]
|
||||
get:
|
||||
operationId: readOutsourcedBusinessPhotoContent
|
||||
summary: "读取当前或精确历史版本的同车照片引用"
|
||||
x-permission-code: "platform:fleet:outsourced-business:list"
|
||||
parameters:
|
||||
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } }
|
||||
- { name: revisionVersion, in: query, schema: { type: integer, format: int64, minimum: 1 } }
|
||||
responses:
|
||||
"200":
|
||||
description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。"
|
||||
content:
|
||||
image/png: { schema: { type: string, format: binary } }
|
||||
image/jpeg: { schema: { type: string, format: binary } }
|
||||
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}:
|
||||
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
|
||||
get:
|
||||
operationId: getOutsourcedBusinessCommand
|
||||
summary: "仅原操作账号查询原命令结果"
|
||||
x-permission-code: "platform:fleet:outsourced-business:list"
|
||||
parameters:
|
||||
- { name: idempotencyKey, in: path, required: true, schema: { type: string, minLength: 8, maxLength: 128 } }
|
||||
responses:
|
||||
"200":
|
||||
description: "同dispatch唯一账本;相同键异内容/版本冲突,异账号拒绝,未知不自动重试。"
|
||||
content:
|
||||
application/json:
|
||||
schema: { "$ref": "#/components/schemas/CommandResponse" }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
parameters:
|
||||
DispatchNo: { name: dispatchNo, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
Slot: { name: slot, in: path, required: true, schema: { "$ref": "#/components/schemas/PhotoSlot" } }
|
||||
responses:
|
||||
DetailResponse:
|
||||
description: "统一ApiResponse;成功code=200,业务拒绝A0400/A0403/A0404/A0409,认证/网关可真实401/403/503;保留requestId/traceId。"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- { "$ref": "#/components/schemas/Envelope" }
|
||||
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Detail" } ], nullable: true } } }
|
||||
schemas:
|
||||
Envelope:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
requestId: { type: string, nullable: true }
|
||||
traceId: { type: string, nullable: true }
|
||||
Operations:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
x-java-record: OutsourcedBusinessModels.Operations
|
||||
required: [money, arrivals]
|
||||
properties:
|
||||
money: { allOf: [ { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Money" } ], nullable: true }
|
||||
arrivals: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Arrival" }, description: "只接受原实际destinationKey,禁止重复或其他车辆目的地;省略目的地或null均不推定到达" }
|
||||
SaveOperations:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
x-java-record: OutsourcedBusinessModels.SaveOperations
|
||||
required: [recordVersion, idempotencyKey, operations]
|
||||
properties:
|
||||
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
|
||||
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
|
||||
operations: { "$ref": "#/components/schemas/Operations" }
|
||||
reason: { type: string, nullable: true, maxLength: 1000 }
|
||||
BindPhoto:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
x-java-record: OutsourcedBusinessModels.BindPhoto
|
||||
required: [recordVersion, idempotencyKey, fileId]
|
||||
properties:
|
||||
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
|
||||
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
|
||||
fileId: { type: string, pattern: "^[1-9][0-9]*$" }
|
||||
Vehicle:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.Vehicle
|
||||
required: [vehicleId, plate, ownership, carrierName]
|
||||
properties:
|
||||
vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" }
|
||||
plate: { type: string, nullable: true }
|
||||
ownership: { type: string, enum: [DISPATCHED] }
|
||||
carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" }
|
||||
PhotoSlot:
|
||||
type: string
|
||||
enum: [LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT]
|
||||
Photo:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.Photo
|
||||
required: [slot, category, fileId, fileName, contentType, sizeBytes, uploadedAt, savedAt]
|
||||
properties:
|
||||
slot: { "$ref": "#/components/schemas/PhotoSlot" }
|
||||
category: { type: string, enum: [LOADING] }
|
||||
fileId: { type: string }
|
||||
fileName: { type: string }
|
||||
contentType: { type: string, enum: [image/png, image/jpeg] }
|
||||
sizeBytes: { type: string }
|
||||
uploadedAt: { type: string, format: date-time }
|
||||
savedAt: { type: string, format: date-time, nullable: true }
|
||||
PhotoSummary:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.PhotoSummary
|
||||
required: [loadingCount]
|
||||
properties:
|
||||
loadingCount: { type: integer, minimum: 0, maximum: 5 }
|
||||
Row:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.Row
|
||||
required: [dispatchNo, stage, plannedDepartureAt, loadingCompletedAt, departedAt, deliveryCompletedAt, origin, vehicle, driver, destinations, actualPalletCount, operations, unitPrice, recordVersion, dispatchRecordVersion, photoSummary, actions, sourceIssues]
|
||||
properties:
|
||||
dispatchNo: { type: string }
|
||||
stage: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" }
|
||||
plannedDepartureAt: { type: string, format: date-time }
|
||||
loadingCompletedAt: { type: string, format: date-time }
|
||||
departedAt: { type: string, format: date-time, nullable: true }
|
||||
deliveryCompletedAt: { type: string, format: date-time, nullable: true }
|
||||
origin: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Origin" }
|
||||
vehicle: { "$ref": "#/components/schemas/Vehicle" }
|
||||
driver: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Driver" }
|
||||
destinations: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Destination" } }
|
||||
actualPalletCount: { type: integer, minimum: 0, description: "原实载warehousePalletId去重;同客户/跨客户共托只计一次,不用标准托位作除数" }
|
||||
operations: { "$ref": "#/components/schemas/Operations" }
|
||||
unitPrice: { type: string, nullable: true, description: "整车金额除实际物理托数,HALF_UP两位;金额null或托数0时null" }
|
||||
recordVersion: { type: string, description: "本页附加版本,初始只读虚拟0;原主单版本独立" }
|
||||
dispatchRecordVersion: { type: string }
|
||||
photoSummary: { "$ref": "#/components/schemas/PhotoSummary" }
|
||||
actions: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Actions" }
|
||||
sourceIssues: { type: array, items: { type: string } }
|
||||
ListResult:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.ListResult
|
||||
required: [records, page, size, total, summary]
|
||||
properties:
|
||||
records: { type: array, items: { "$ref": "#/components/schemas/Row" } }
|
||||
page: { type: integer }
|
||||
size: { type: integer }
|
||||
total: { type: integer }
|
||||
summary: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Summary" }
|
||||
Detail:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.Detail
|
||||
required: [row, dispatch, cargo, photos]
|
||||
properties:
|
||||
row: { "$ref": "#/components/schemas/Row" }
|
||||
dispatch: { "$ref": "overseas-business-v1.yaml#/components/schemas/DispatchDetail" }
|
||||
cargo: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Cargo" } }
|
||||
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
|
||||
History:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.History
|
||||
required: [eventSource, recordVersion, dispatchRecordVersion, action, operatorName, occurredAt, reason, operations, photos]
|
||||
properties:
|
||||
eventSource: { type: string, enum: [DISPATCH, OPERATIONS] }
|
||||
recordVersion: { type: string, nullable: true }
|
||||
dispatchRecordVersion: { type: string, nullable: true }
|
||||
action: { type: string }
|
||||
operatorName: { type: string, nullable: true }
|
||||
occurredAt: { type: string, format: date-time }
|
||||
reason: { type: string, nullable: true }
|
||||
operations: { allOf: [ { "$ref": "#/components/schemas/Operations" } ], nullable: true }
|
||||
photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } }
|
||||
HistoryPage:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.HistoryPage
|
||||
required: [records, page, size, total]
|
||||
properties:
|
||||
records: { type: array, items: { "$ref": "#/components/schemas/History" } }
|
||||
page: { type: integer }
|
||||
size: { type: integer }
|
||||
total: { type: integer }
|
||||
Command:
|
||||
type: object
|
||||
x-java-record: OutsourcedBusinessModels.Command
|
||||
required: [dispatchNo, action, idempotencyKey, state, recordVersion, replayed, result, errorCode, retryAllowed]
|
||||
properties:
|
||||
dispatchNo: { type: string }
|
||||
action: { type: string, enum: [SAVE_OPERATIONS, UPLOAD_PHOTO, BIND_PHOTO] }
|
||||
idempotencyKey: { type: string }
|
||||
state: { type: string, enum: [SUCCEEDED, PENDING, UNKNOWN_RESULT, REJECTED] }
|
||||
recordVersion: { type: string, nullable: true }
|
||||
replayed: { type: boolean }
|
||||
result: { nullable: true, oneOf: [ { "$ref": "#/components/schemas/Detail" }, { "$ref": "#/components/schemas/Photo" } ] }
|
||||
errorCode: { type: string, nullable: true }
|
||||
retryAllowed: { type: boolean, enum: [false] }
|
||||
ListResponse:
|
||||
allOf:
|
||||
- { "$ref": "#/components/schemas/Envelope" }
|
||||
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/ListResult" } ], nullable: true } } }
|
||||
HistoryResponse:
|
||||
allOf:
|
||||
- { "$ref": "#/components/schemas/Envelope" }
|
||||
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/HistoryPage" } ], nullable: true } } }
|
||||
PhotoResponse:
|
||||
allOf:
|
||||
- { "$ref": "#/components/schemas/Envelope" }
|
||||
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Photo" } ], nullable: true } } }
|
||||
CommandResponse:
|
||||
allOf:
|
||||
- { "$ref": "#/components/schemas/Envelope" }
|
||||
- { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Command" } ], nullable: true } } }
|
||||
@@ -237,15 +237,6 @@ paths:
|
||||
-
|
||||
"$ref": "#/components/schemas/Photo"
|
||||
nullable: true
|
||||
parameters:
|
||||
-
|
||||
name: "idempotencyKey"
|
||||
in: "query"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -254,7 +245,13 @@ paths:
|
||||
type: "object"
|
||||
required:
|
||||
- "file"
|
||||
- "idempotencyKey"
|
||||
properties:
|
||||
idempotencyKey:
|
||||
type: "string"
|
||||
minLength: 8
|
||||
maxLength: 128
|
||||
description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。"
|
||||
file:
|
||||
type: "string"
|
||||
format: "binary"
|
||||
|
||||
+29
-5
@@ -1,6 +1,10 @@
|
||||
package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
@@ -18,22 +22,29 @@ class DispatchNumberSequenceTest {
|
||||
final JdbcTemplate jdbc;
|
||||
final DataSourceTransactionManager transactions;
|
||||
final DispatchNumberSequence sequence;
|
||||
Fixture() {
|
||||
final DispatchNumberSequenceMapper mapper;
|
||||
/** 隔离库造表只使用测试 JDBC,被测分配和读取均由实际 Mapper 执行。 */
|
||||
Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)");
|
||||
var proxy=new ProxyFactory(new DispatchNumberSequence(jdbc));proxy.setProxyTargetClass(true);
|
||||
var configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class);
|
||||
var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||
sequence=(DispatchNumberSequence)proxy.getProxy();
|
||||
}
|
||||
}
|
||||
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() {
|
||||
/** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */
|
||||
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception {
|
||||
var f=new Fixture();var today=LocalDate.of(2026,10,20);
|
||||
assertThat(f.sequence.next(today)).isEqualTo(1);
|
||||
assertThat(f.sequence.next(today)).isEqualTo(2);
|
||||
assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1);
|
||||
assertThat(f.sequence.next(today)).isEqualTo(3);
|
||||
}
|
||||
/** 两个真实事务连接争用同一天主键,全部已提交号码唯一且连续。 */
|
||||
@Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception {
|
||||
var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
@@ -43,17 +54,30 @@ class DispatchNumberSequenceTest {
|
||||
assertThat(values.last()).isEqualTo(64L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
@Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() {
|
||||
/** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */
|
||||
@Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() throws Exception {
|
||||
var f=new Fixture();var date=LocalDate.of(2026,10,20);
|
||||
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||
assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(f.sequence.next(date)).isEqualTo(2);
|
||||
}
|
||||
@Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() {
|
||||
/** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */
|
||||
@Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() throws Exception {
|
||||
var f=new Fixture();var date=LocalDate.of(2026,10,20);
|
||||
f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date);
|
||||
assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满");
|
||||
assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L);
|
||||
}
|
||||
|
||||
/** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */
|
||||
@Test void connectionResultBypassesSessionCacheInTheSameTransaction() throws Exception {
|
||||
var f=new Fixture();
|
||||
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -148,6 +148,33 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4);
|
||||
}
|
||||
|
||||
/** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */
|
||||
@ParameterizedTest @ValueSource(longs={0,-1})
|
||||
void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception {
|
||||
var f=new Merged(false);
|
||||
for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601,
|
||||
"overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id,
|
||||
"warehouse_location_code","LOC-1","hold_packages",1);
|
||||
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源",
|
||||
"total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING",
|
||||
"operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102,
|
||||
"source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING",
|
||||
"destination_fba_warehouse_code","ONT8");
|
||||
f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED");
|
||||
var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null));
|
||||
var groups=f.rules.selectionGroups(candidates);
|
||||
assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L));
|
||||
var group=groups.get("RELABEL:RL-A:501");
|
||||
assertThat(group.complete()).isFalse();
|
||||
assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失"));
|
||||
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo)
|
||||
.containsExactlyInAnyOrder("RL-A","RL-B");
|
||||
assertThat(group.pallets()).hasSize(1);
|
||||
assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList()))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
|
||||
}
|
||||
|
||||
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
|
||||
private static final class Merged {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.controller.OutsourcedBusinessController;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** HTTP公开JSON精简及拒绝行为,防止仅页面隐藏不支持字段。 */
|
||||
class OutsourcedBusinessContractTest {
|
||||
/** 即使全局允许未知字段,本域仍明确拒绝油耗、里程及客户端归属。 */
|
||||
@Test void unsupportedOperationsAreRejectedEvenWithLenientGlobalJackson() throws Exception {
|
||||
var json=new ObjectMapper().findAndRegisterModules().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
String body="{\"recordVersion\":\"0\",\"idempotencyKey\":\"test-original-key\",\"operations\":{\"money\":null,\"arrivals\":[]}}";
|
||||
assertNotNull(json.readValue(body,OutsourcedBusinessModels.SaveOperations.class));
|
||||
for(String field:List.of("actualDistanceMiles","fuelConsumptionLitresPer100Km","actualFuelLitres","ownership","scope")) {
|
||||
String extra=body.replace("\"arrivals\":[]","\"arrivals\":[],\""+field+"\":null");
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue(extra,OutsourcedBusinessModels.SaveOperations.class),field);
|
||||
}
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue(body.replace("\"recordVersion\":\"0\"","\"recordVersion\":\"0\",\"scope\":\"OWNED\""),OutsourcedBusinessModels.SaveOperations.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("{\"recordVersion\":\"0\",\"idempotencyKey\":\"bind-original\",\"fileId\":\"9\",\"ownership\":\"OWNED\"}",OutsourcedBusinessModels.BindPhoto.class));
|
||||
}
|
||||
/** 外配输出没有油耗字段和摘要,自有原JSON字段继续存在。 */
|
||||
@Test void outsourcedSerializationIsLeanAndOwnedPublicShapeRemains() throws Exception {
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
var output=json.valueToTree(new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),List.of()));
|
||||
assertEquals(2,output.size());assertTrue(output.has("money"));assertTrue(output.has("arrivals"));
|
||||
assertFalse(output.has("actualDistanceMiles"));assertFalse(output.has("fuelConsumptionLitresPer100Km"));
|
||||
assertEquals(1,json.valueToTree(new OutsourcedBusinessModels.PhotoSummary(5)).size());
|
||||
var external=json.valueToTree(new OutsourcedBusinessModels.Vehicle(null,"TEST-EXT","DISPATCHED",null));
|
||||
assertTrue(external.get("vehicleId").isNull());assertTrue(external.get("carrierName").isNull());assertEquals(4,external.size());
|
||||
var owned=json.valueToTree(new OwnedBusinessModels.Operations(null,null,null,List.of()));
|
||||
assertTrue(owned.has("actualDistanceMiles"));assertTrue(owned.has("fuelConsumptionLitresPer100Km"));
|
||||
assertEquals(3,json.valueToTree(new OwnedBusinessModels.Vehicle("7","TEST-OWN","OWNED")).size());
|
||||
}
|
||||
/** 油耗框不是外配合法URL枚举,不能先上传再在页面隐藏。 */
|
||||
@Test void onlyFiveLoadingPhotoSlotsCanDeserialize() throws Exception {
|
||||
var json=new ObjectMapper();assertEquals(5,OutsourcedBusinessModels.PhotoSlot.values().length);
|
||||
for(var slot:OutsourcedBusinessModels.PhotoSlot.values())assertEquals(slot,json.readValue("\""+slot.name()+"\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_START\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_END\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
}
|
||||
/** 八类HTTP操作各用外配真实权限,不将外配权限伪装为自有。 */
|
||||
@Test void controllerAlwaysRequiresTheActualOutsourcedPermission() {
|
||||
int count=0;
|
||||
for(var method:OutsourcedBusinessController.class.getDeclaredMethods()) {
|
||||
var authorization=method.getAnnotation(PreAuthorize.class);if(authorization==null)continue;count++;
|
||||
String action=switch(method.getName()){case "save"->"edit";case "upload","bind"->"upload";default->"list";};
|
||||
assertEquals("hasAuthority('platform:fleet:outsourced-business:"+action+"')",authorization.value());
|
||||
assertTrue(Arrays.stream(method.getParameterTypes()).noneMatch(p->p.getSimpleName().equals("OwnedBusinessScope")));
|
||||
}
|
||||
assertEquals(8,count);
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 复用既有四表测试装配,实际Mapper、事务、权限与原键一并验收。 */
|
||||
class OwnedBusinessScopePersistenceTest {
|
||||
final OwnedBusinessPersistenceTest baseline=new OwnedBusinessPersistenceTest();
|
||||
OwnedBusinessQueryService queries;
|
||||
OwnedBusinessRecordService records;
|
||||
OwnedBusinessPhotoService photos;
|
||||
OwnedBusinessFileGateway files;
|
||||
OutsourcedBusinessService outsourced;
|
||||
final Map<Long,OwnedBusinessFileGateway.Evidence> uploaded=new HashMap<>();
|
||||
final byte[] png={(byte)137,80,78,71,13,10,26,10,0};
|
||||
/** 只调用原测试的建库装配,不继承或重复运行其@Test。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
baseline.setup();var reader=mock(DispatchDetailReader.class);
|
||||
when(reader.detail(any(),anyString())).thenAnswer(call->{
|
||||
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
|
||||
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
|
||||
});
|
||||
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
|
||||
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
|
||||
records=tx(new OwnedBusinessRecordService(baseline.repository,queries,baseline.projection,commands,baseline.codec));
|
||||
files=mock(OwnedBusinessFileGateway.class);var sequence=new AtomicLong(900);
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{
|
||||
long id=sequence.incrementAndGet();var evidence=new OwnedBusinessFileGateway.Evidence(id,"TEST-"+id+".png","image/png",png.length,"2026-10-20T08:00:00Z");uploaded.put(id,evidence);return evidence;
|
||||
});
|
||||
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->uploaded.get(call.<Long>getArgument(2)));
|
||||
when(files.content(anyLong(),any(),anyLong())).thenReturn(png);
|
||||
photos=tx(new OwnedBusinessPhotoService(queries,records,baseline.repository,commands,baseline.projection,baseline.codec,files));
|
||||
outsourced=new OutsourcedBusinessService(queries,records,photos,baseline.projection,baseline.codec);
|
||||
insert(2,"MASTER","AFTER_LOADING","TEST原承运商");insert(3,"EXTERNAL","DELIVERING",null);insert(4,"MASTER","DELIVERED","TEST另一承运商");
|
||||
}
|
||||
/** 数据库仅在本机隔离H2,用后关闭。 */
|
||||
@AfterEach void close(){baseline.closeDatabase();}
|
||||
/** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */
|
||||
@SuppressWarnings("unchecked") private <T>T tx(T target) {
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(baseline.manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
|
||||
}
|
||||
/** 两类外配身份固定原快照,实际托盘号和两个客户仍逐项保留。 */
|
||||
private void insert(long id,String mode,String stage,String carrier) {
|
||||
var original=OwnedBusinessFixtures.source();
|
||||
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of());
|
||||
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver()));
|
||||
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
|
||||
baseline.jdbc.update("""
|
||||
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
|
||||
loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1
|
||||
""",id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id);
|
||||
baseline.jdbc.update("""
|
||||
INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type,
|
||||
destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1
|
||||
""",id*100,id,"PAL-"+(31+id),31+id);
|
||||
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId());
|
||||
ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L);
|
||||
ownership.setLoadingCompletionHash(original.getLoadingCompletionHash());ownership.setLoadingCompletedAt(original.getLoadingCompletedAt());ownership.setOwnershipCapturedAt(original.getLoadingCompletedAt());baseline.repository.capture(ownership);
|
||||
baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt());
|
||||
if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id);
|
||||
if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id);
|
||||
}
|
||||
/** 外配真实三权限,与自有集合完全不相交。 */
|
||||
private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));}
|
||||
/** 请求只包含金额和本车目的地到达,版本与原键逐次显式提供。 */
|
||||
private OutsourcedBusinessModels.SaveOperations request(String version,String key,OwnedBusinessModels.Money money,List<OwnedBusinessModels.Arrival> arrivals) {
|
||||
return new OutsourcedBusinessModels.SaveOperations(version,key,new OutsourcedBusinessModels.Operations(money,arrivals),"本机TEST承运登记");
|
||||
}
|
||||
/** 真实SQL统计跨页且按归属、阶段、日期、车牌和目的仓共同过滤。 */
|
||||
@Test void fullSummaryAndFilterUseOneFrozenScope() {
|
||||
var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,"9");var result=outsourced.list(actor(),query);
|
||||
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(new OwnedBusinessModels.Summary(3,1,1,1),result.summary());
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"TEST-OUT-3",null,"9")).total());
|
||||
assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"ONT8",null,"9")).total());
|
||||
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,"other")).total());
|
||||
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total());
|
||||
for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total());
|
||||
}
|
||||
/** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */
|
||||
@Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() {
|
||||
var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3");
|
||||
assertEquals("7",master.row().vehicle().vehicleId());assertEquals("TEST原承运商",master.row().vehicle().carrierName());
|
||||
assertNull(external.row().vehicle().vehicleId());assertNull(external.row().vehicle().carrierName());
|
||||
assertEquals("DISPATCHED",external.row().vehicle().ownership());assertEquals("0",master.row().recordVersion());
|
||||
assertEquals(2,master.cargo().size());assertEquals(1,master.row().actualPalletCount());assertTrue(master.row().actions().edit().allowed());
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
baseline.jdbc.update("DELETE FROM ll_fleet_owned_business_ownership WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));assertEquals(2,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,null)).total());assertNull(baseline.repository.ownership(2));
|
||||
}
|
||||
/** 实际服务权限与原归属双向隔离,不允许靠共享四表越权。 */
|
||||
@Test void bothPermissionAndDispatchScopeAreRequiredOnReadWriteAndCommand() {
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
|
||||
assertThrows(ResponseStatusException.class,()->queries.detail(actor(),"DS-OWN-ONE"));
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OWN-ONE"));
|
||||
assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OWN-ONE",request("0","foreign-scope-save",null,List.of())));
|
||||
assertThrows(BizException.class,()->outsourced.command(actor(),"DS-OWN-ONE","foreign-scope-key"));
|
||||
assertThrows(BizException.class,()->photos.command(OwnedBusinessFixtures.actor(),"DS-OUT-2","foreign-scope-key"));
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.save(OwnedBusinessFixtures.actor(),"DS-OUT-2",request("0","wrong-permission-write",null,List.of())));
|
||||
var all=new HashSet<>(actor().permissions());all.addAll(OwnedBusinessFixtures.actor().permissions());
|
||||
var both=new OwnedBusinessModels.Actor("both-scopes","TEST双权限操作员",all);
|
||||
assertThrows(BizException.class,()->outsourced.detail(both,"DS-OWN-ONE"));assertThrows(BizException.class,()->queries.detail(both,"DS-OUT-2"));
|
||||
assertThrows(BizException.class,()->records.save(both,"DS-OUT-2",new OwnedBusinessModels.SaveOperations("0","wrong-owned-write",new OwnedBusinessModels.Operations(null,null,null,List.of()),null)));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
}
|
||||
/** 多客户同一物理托盘只除一次,逐目的地登记、零值与NULL清空都走真实CAS。 */
|
||||
@Test void samePhysicalPalletMultipleDestinationsAndMoneyClearUseRealCas() {
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE id=212");
|
||||
var arrivals=List.of(new OwnedBusinessModels.Arrival("FBA:ONT8","2026-10-20T08:10:00Z"),new OwnedBusinessModels.Arrival("FBA:LAX9","2026-10-20T08:20:00Z"));
|
||||
var body=request("0","carrier-amount-original",new OwnedBusinessModels.Money("CNY","60"),arrivals);
|
||||
var first=outsourced.save(actor(),"DS-OUT-2",body);
|
||||
assertEquals("60.00",first.row().unitPrice());assertEquals(2,first.row().destinations().size());assertTrue(first.row().destinations().stream().allMatch(d->d.actualArrivedAt()!=null));
|
||||
assertEquals(first,outsourced.save(actor(),"DS-OUT-2",body));
|
||||
assertEquals(first,outsourced.command(actor(),"DS-OUT-2",body.idempotencyKey()).result());
|
||||
var stale=assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","carrier-amount-stale",null,List.of())));assertEquals(ResultCode.CONFLICT,stale.getResultCode());
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0",body.idempotencyKey(),new OwnedBusinessModels.Money("USD","0"),arrivals)));
|
||||
var zero=outsourced.save(actor(),"DS-OUT-2",request("1","carrier-amount-zero",new OwnedBusinessModels.Money("USD","0"),arrivals));
|
||||
assertEquals("0.00",zero.row().unitPrice());assertEquals("USD",zero.row().operations().money().currency());
|
||||
var cleared=outsourced.save(actor(),"DS-OUT-2",request("2","carrier-amount-clear",null,List.of()));
|
||||
assertNull(cleared.row().operations().money());assertNull(cleared.row().unitPrice());assertTrue(cleared.row().destinations().stream().allMatch(d->d.actualArrivedAt()==null));
|
||||
assertNull(baseline.repository.record(2).getCurrency());assertNull(baseline.repository.record(2).getActualDistanceMiles());assertNull(baseline.repository.record(2).getFuelConsumptionLitresPer100Km());
|
||||
assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
|
||||
}
|
||||
/** 油耗及里程在共享服务再次拒绝,不能绕过外配DTO直接调用内部入口。 */
|
||||
@Test void unsupportedFuelAndForeignDestinationAreRejectedBeforeAnyWrite() {
|
||||
var body=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-write",new OwnedBusinessModels.Operations(null,"1",null,List.of()),null);
|
||||
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",body,OwnedBusinessScope.OUTSOURCED));
|
||||
var fuel=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-rate",new OwnedBusinessModels.Operations(null,null,"1",List.of()),null);
|
||||
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",fuel,OwnedBusinessScope.OUTSOURCED));
|
||||
assertThrows(BizException.class,()->photos.upload(actor(),"DS-OUT-2",OwnedBusinessModels.PhotoSlot.FUEL_START,"illegal-fuel-photo",new MockMultipartFile("file",png),OwnedBusinessScope.OUTSOURCED));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","illegal-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:OTHER",null)))));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","duplicate-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:ONT8",null),new OwnedBusinessModels.Arrival("FBA:ONT8",null)))));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));verifyNoInteractions(files);
|
||||
}
|
||||
/** 五框上传/绑定/原键恢复及历史读取保持同车原引用,异账号不能复用。 */
|
||||
@Test void fivePhotosReplayHistoryAndActorIsolationDoNotChangeDispatch() {
|
||||
String version="0",firstFile=null;int index=0;
|
||||
for(var slot:OutsourcedBusinessModels.PhotoSlot.values()) {
|
||||
var file=new MockMultipartFile("file","TEST.png","image/png",png);String uploadKey="out-upload-"+slot;
|
||||
var photo=outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file);assertNull(photo.savedAt());
|
||||
assertEquals(photo,outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file));
|
||||
assertEquals(version,outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
|
||||
var binding=new OutsourcedBusinessModels.BindPhoto(version,"out-bind-"+slot,photo.fileId());
|
||||
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,binding);assertEquals(bound,outsourced.bind(actor(),"DS-OUT-2",slot,binding));version=bound.row().recordVersion();
|
||||
if(index++==0)firstFile=photo.fileId();assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",slot,Long.parseLong(photo.fileId()),null).content());
|
||||
}
|
||||
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
|
||||
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
||||
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId()));
|
||||
final long oldId=Long.parseLong(firstFile);
|
||||
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
|
||||
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
|
||||
var history=outsourced.history(actor(),"DS-OUT-2",1,200);assertEquals(7,history.total());
|
||||
assertTrue(history.records().stream().anyMatch(h->h.eventSource().equals("DISPATCH") && h.recordVersion()==null && h.dispatchRecordVersion().equals("8") && h.operations()==null));
|
||||
assertTrue(history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).allMatch(h->h.dispatchRecordVersion()==null && h.reason().startsWith("保存")));
|
||||
var other=new OwnedBusinessModels.Actor("other","TEST另一外配账号",actor().permissions());
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.command(other,"DS-OUT-2","out-upload-LOADING_SCENE"));
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.upload(other,"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-upload-LOADING_SCENE",new MockMultipartFile("file","TEST.png","image/png",png)));
|
||||
verify(files,times(6)).upload(anyLong(),any(),anyString(),any());assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
|
||||
}
|
||||
/** File结果未知只保存原意图,任何同键重放不得再次写File。 */
|
||||
@Test void unknownUploadIsRecoverableButNeverAutomaticallyRetried() {
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("本机模拟连接中断"));
|
||||
var file=new MockMultipartFile("file","TEST.png","image/png",png);
|
||||
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
|
||||
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
|
||||
var command=outsourced.command(actor(),"DS-OUT-2","out-upload-unknown");assertEquals("UNKNOWN_RESULT",command.state());assertFalse(command.retryAllowed());assertNull(command.result());
|
||||
assertEquals("OUTSOURCED_UPLOAD_UNKNOWN_RESULT",command.errorCode());verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 原装车ID、模式、hash、时间任何一项不一致都不能进入外配详情。 */
|
||||
@Test void frozenIdentityHashAndTimeCannotBeSubstituted() {
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=999 WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=7,loading_completion_hash='different' WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash='actual-load',loading_completed_at=? WHERE dispatch_id=2",OwnedBusinessFixtures.source().getLoadingCompletedAt().plusSeconds(1));
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
String external=baseline.repository.source("DS-OUT-3").getResourcesJson().replace("\"vehicleId\":null","\"vehicleId\":99");
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=3",external);
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3"));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
}
|
||||
/** 承运金额按两实托HALF_UP两位,零实托不能伪造单价或使用来源条数替代。 */
|
||||
@Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() {
|
||||
String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},");
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load);
|
||||
baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211");
|
||||
var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of()));
|
||||
assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice());
|
||||
var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class);
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",baseline.codec.write(new DispatchContracts.ConfirmedLoad(confirmation.manifestHash(),List.of(),confirmation.operatorId(),confirmation.confirmedAt())));
|
||||
var empty=outsourced.detail(actor(),"DS-OUT-2");assertEquals(0,empty.row().actualPalletCount());assertNull(empty.row().unitPrice());assertFalse(empty.row().sourceIssues().isEmpty());
|
||||
}
|
||||
/** 元数据暂不可读时原绑定体和fileId不丢失,恢复后不重复上传。 */
|
||||
@Test void failedBindRetainsOriginalFileAndKeyForExplicitRecovery() {
|
||||
var slot=OutsourcedBusinessModels.PhotoSlot.LOADING_LIST;
|
||||
var uploadedPhoto=outsourced.upload(actor(),"DS-OUT-2",slot,"out-bind-failure-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
||||
long fileId=Long.parseLong(uploadedPhoto.fileId());
|
||||
when(files.metadata(2,OwnedBusinessModels.PhotoSlot.LOADING_LIST,fileId)).thenThrow(new BizException(ResultCode.CONFLICT,"本机模拟元数据暂不可读")).thenReturn(uploaded.get(fileId));
|
||||
var body=new OutsourcedBusinessModels.BindPhoto("0","out-bind-failure-key",uploadedPhoto.fileId());
|
||||
assertThrows(BizException.class,()->outsourced.bind(actor(),"DS-OUT-2",slot,body));
|
||||
assertEquals("0",outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
|
||||
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,body);assertEquals(uploadedPhoto.fileId(),bound.photos().get(0).fileId());
|
||||
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 完整摘要严格匹配,同前缀末位、大小写和尾空格均不能误命中。 */
|
||||
@Test void queryHashComparesCompleteValueRatherThanDefaultBinaryWidth() {
|
||||
String prefix="a".repeat(63),exact=prefix+"1";
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_completion_hash=? WHERE id=1",exact);
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
|
||||
var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null);
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
for(String different:List.of(prefix+"2","A"+exact.substring(1),prefix+" ")) {
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",different);
|
||||
var result=queries.list(OwnedBusinessFixtures.actor(),query);assertEquals(0,result.total());assertTrue(result.records().isEmpty());
|
||||
}
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
}
|
||||
/** 历史投影统一字符比较规则,完整保留中文、原尾空格、动作和原操作账号。 */
|
||||
@Test void historyCharacterProjectionPreservesOriginalTextAndSnapshot() {
|
||||
// 原测试装配的原生历史采用较短替身列,这里只在隔离H2对齐实际原表上限。
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN operator_id VARCHAR(128)");
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN reason VARCHAR(2000)");
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN action VARCHAR(40)");
|
||||
String nativeReason="原排车完整中文历史🌏"+"中".repeat(1200)+" ",operationReason="整车承运登记完整中文说明🌏 ",operator="o".repeat(127)+"X",nativeAction="A".repeat(39)+"X";
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_history SET reason=?,operator_id=?,action=? WHERE dispatch_id=2",nativeReason,operator,nativeAction);
|
||||
var saved=outsourced.save(actor(),"DS-OUT-2",new OutsourcedBusinessModels.SaveOperations("0","history-character-original",
|
||||
new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("CNY","30"),List.of()),operationReason));
|
||||
var history=outsourced.history(actor(),"DS-OUT-2",1,20);assertEquals(2,history.total());
|
||||
var original=history.records().stream().filter(h->h.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
|
||||
var operations=history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
|
||||
assertEquals(nativeReason,original.reason());assertEquals(nativeAction,original.action());
|
||||
assertEquals(operationReason,operations.reason());assertEquals("SAVE_OPERATIONS",operations.action());
|
||||
assertEquals(saved.row().operations(),operations.operations());assertEquals("TEST外配操作员",operations.operatorName());
|
||||
var raw=baseline.repository.history(2,0,20).stream().filter(h->h.getEventSource().equals("DISPATCH")).findFirst().orElseThrow();
|
||||
assertEquals(operator,raw.getOperatorId());
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,11 @@
|
||||
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
|
||||
],
|
||||
"reviewedClassDependencies": [
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels",
|
||||
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
|
||||
"reason": "外配装车后视图组合原排车公开只读详情,复用同一履约事实,不读取实体或写主单。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels",
|
||||
"target": "com.baiyue.lastleg.dispatch.port.DispatchViews",
|
||||
|
||||
Reference in New Issue
Block a user