From 9f3755f8e246f763054d1048e58ef52f7e0fca39 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 18:32:49 +0800 Subject: [PATCH] Add verified outsourced scope to original dispatch operations --- deploy/sql/头程数据库统一增量-MySQL8.sql | 22 +- .../main/resources/db/lastleg-permissions.sql | 5 +- ...BusinessPermissionSeedSqlContractTest.java | 45 +++ .../analyze-lastleg-dependencies.test.mjs | 18 +- .../OutsourcedBusinessController.java | 80 +++++ .../model/OutsourcedBusinessModels.java | 48 +++ .../service/OutsourcedBusinessService.java | 94 ++++++ .../mapper/OwnedBusinessQueryMapper.java | 4 +- .../model/OwnedBusinessRows.java | 2 +- .../model/OwnedBusinessScope.java | 26 ++ .../service/OwnedBusinessPhotoService.java | 41 ++- .../service/OwnedBusinessProjection.java | 18 +- .../service/OwnedBusinessQueryService.java | 58 +++- .../service/OwnedBusinessRecordService.java | 30 +- .../service/OwnedBusinessSupport.java | 7 +- .../openapi/fleet-outsourced-business-v1.yaml | 303 ++++++++++++++++++ .../OutsourcedBusinessContractTest.java | 59 ++++ .../OwnedBusinessScopePersistenceTest.java | 232 ++++++++++++++ .../lastleg-slice-dependencies.json | 5 + 19 files changed, 1057 insertions(+), 40 deletions(-) create mode 100644 iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8f6843980..1ac21825d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 994f145a4..c65f73eb3 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -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; diff --git a/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java new file mode 100644 index 000000000..7abc0c123 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java @@ -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))"); + } + } +} diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index aa50f7c47..fa1c086b4 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java new file mode 100644 index 000000000..7b89a95e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java @@ -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 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(@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 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 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 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 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 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(@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);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java new file mode 100644 index 000000000..eadd9cb31 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java @@ -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 destinations, + Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion, + PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List sourceIssues) {} + public record ListResult(List records,int page,int size,int total,OwnedBusinessModels.Summary summary) {} + public record Detail(Row row,DispatchViews.Detail dispatch,List cargo,List photos) {} + public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List photos) {} + public record HistoryPage(List 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);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java new file mode 100644 index 000000000..7052b263e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java @@ -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());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java index 45588ded0..c72c3125c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -19,7 +19,9 @@ public interface OwnedBusinessQueryMapper { 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 o.loading_completion_hash=d.loading_completion_hash + AND o.loading_completed_at=d.loading_completed_at + AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL')) AND d.planned_departure_at >= #{q.from} AND d.planned_departure_at < #{q.to} AND d.stage=#{q.stage} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java index 20be59f32..38e66f20d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -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; } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java new file mode 100644 index 000000000..fcefe58c5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java @@ -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?"自有":"外配")+"业务");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java index 12cec0302..0a6074143 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java @@ -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 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 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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java index 35223717d..c3d96d31a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -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 cargo,List batches,Actor actor) { + return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED); + } + /** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */ + public Row row(Source source,Record record,List cargo,List 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();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); var destinations=new LinkedHashMap(); 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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index 66a102f83..d9fb9d19e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -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 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();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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java index 581205798..1ec0b5a54 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java @@ -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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java index 048c651df..59c4f2cef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java @@ -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(); diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml new file mode 100644 index 000000000..bba8b7f06 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -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 } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java new file mode 100644 index 000000000..ddc0d4ca8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java @@ -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); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java new file mode 100644 index 000000000..b28002413 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -0,0 +1,232 @@ +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 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.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 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 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()); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 578a127a9..7dc76b0b9 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -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",