This commit is contained in:
2026-10-21 17:08:52 +08:00
parent 4a0e86bd42
commit 0c6c55f6f1
4 changed files with 480 additions and 22 deletions
@@ -0,0 +1,393 @@
openapi: 3.0.3
info:
title: 排车装车资料(Admin/PDA共用)
version: 1.0.0
description: 装车完成进入AFTER_LOADING。五个整车照片框均已保存时,在最后一次绑定事务内自动生成派送并进入DELIVERING;失败回滚该次绑定和派送,前四张与已确认实载保留。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
get:
operationId: getDispatchLoadingDocuments
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents:
get:
operationId: getDispatchLoadingDocumentsPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
post:
operationId: uploadDispatchLoadingPhoto
summary: 上传整车装车照片,尚未绑定
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- idempotencyKey
properties:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
idempotencyKey:
type: string
minLength: 8
maxLength: 128
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads:
post:
operationId: uploadDispatchLoadingPhotoPda
summary: 上传整车装车照片,尚未绑定
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- idempotencyKey
properties:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
idempotencyKey:
type: string
minLength: 8
maxLength: 128
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
put:
operationId: bindDispatchLoadingPhoto
summary: 保存装车照片;五框齐全时自动进入派送中
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BindPhoto'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}:
put:
operationId: bindDispatchLoadingPhotoPda
summary: 保存装车照片;五框齐全时自动进入派送中
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Documents'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BindPhoto'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
get:
operationId: readDispatchLoadingPhoto
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:list
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
- $ref: '#/components/parameters/fileId'
responses:
'200':
description: no-store图片内容
content:
image/jpeg:
schema:
type: string
format: binary
image/png:
schema:
type: string
format: binary
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content:
get:
operationId: readDispatchLoadingPhotoPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/slot'
- $ref: '#/components/parameters/fileId'
responses:
'200':
description: no-store图片内容
content:
image/jpeg:
schema:
type: string
format: binary
image/png:
schema:
type: string
format: binary
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
get:
operationId: recoverDispatchLoadingPhoto
summary: 读取本车装车资料或原上传结果
x-permission-code: platform:overseas-warehouse:dispatch:edit
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/idempotencyKey'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}:
get:
operationId: recoverDispatchLoadingPhotoPda
summary: 读取本车装车资料或原上传结果
x-permission-code: lastleg:pda:operate
parameters:
- $ref: '#/components/parameters/dispatchNo'
- $ref: '#/components/parameters/idempotencyKey'
responses:
'200':
description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
data:
$ref: '#/components/schemas/Photo'
x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
dispatchNo:
name: dispatchNo
in: path
required: true
schema:
type: string
slot:
name: slot
in: path
required: true
schema:
type: string
enum: &id001
- LOADING_SCENE
- LOADING_LIST
- PRE_DEPARTURE_CHECK
- DRIVER_LICENSE
- RECEIPT
fileId:
name: fileId
in: path
required: true
schema:
type: string
idempotencyKey:
name: idempotencyKey
in: path
required: true
schema:
type: string
schemas:
Photo:
type: object
required:
- slot
- fileId
- fileName
- contentType
- sizeBytes
- uploadedAt
properties:
slot:
type: string
enum: *id001
category:
type: string
enum:
- LOADING
fileId:
type: string
fileName:
type: string
contentType:
type: string
sizeBytes:
type: string
uploadedAt:
type: string
savedAt:
type: string
nullable: true
Documents:
type: object
required:
- dispatchNo
- recordVersion
- editable
- photos
- dispatchRecordVersion
- stage
- complete
properties:
dispatchNo:
type: string
recordVersion:
type: string
editable:
type: boolean
photos:
type: array
items:
$ref: '#/components/schemas/Photo'
dispatchRecordVersion:
type: string
stage:
type: string
enum:
- AFTER_LOADING
- DELIVERING
- DELIVERED
complete:
type: boolean
description: 五个固定框各有一张已保存照片
BindPhoto:
type: object
required:
- recordVersion
- idempotencyKey
- fileId
properties:
recordVersion:
type: string
pattern: ^(0|[1-9][0-9]*)$
idempotencyKey:
type: string
minLength: 8
maxLength: 128
fileId:
type: string
@@ -1729,7 +1729,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。
security:
- bearerAuth: []
responses:
@@ -254,7 +254,7 @@ paths:
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。
security:
- bearerAuth: []
responses:
@@ -1,6 +1,14 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.BindPhoto;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import java.nio.file.*;
import java.util.regex.Pattern;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
@@ -19,43 +27,53 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
class DispatchLoadingDeparturePersistenceTest {
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
/** 装车完成不发车,第五张保存自动发车,旧装车键保持原装车后结果。 */
@Test void loadingWaitsForFivePhotosThenDepartsOnce() throws Exception {
authorize();var f=new Fixture();
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
for(int i=0;i<4;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("AFTER_LOADING");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING");
assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5);
var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,PhotoSlot.RECEIPT,new BindPhoto("4","photo-bind-4","1004")));
assertThat(replayPhoto).isEqualTo(fifth);
assertThat(result.counts().shippedPackageCount()).isZero();assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING");
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("AFTER_LOADING");
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING);
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.AFTER_LOADING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
}
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception {
/** 派送失败回滚第五张绑定,保留已确认装车和前四张资料,原文件可重试。 */
@Test void preparationAndDepartureFailuresRollBackTheFifthPhoto() throws Exception {
authorize();
for(boolean failPreparation:List.of(true,false)) {
var f=new Fixture();
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class);
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
assertThatThrownBy(()->f.bind(4,f.f.actor)).isInstanceOf(IllegalStateException.class);
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("AFTER_LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version+1);
assertThat(row.getLoadingCompletionHash()).isNotNull();
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(4);assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
}
@@ -68,12 +86,12 @@ class DispatchLoadingDeparturePersistenceTest {
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
}
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
/** 同一版本后台/PDA同时确认只有一次装车提交,资料未齐不发车。 */
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
@@ -81,20 +99,52 @@ class DispatchLoadingDeparturePersistenceTest {
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();}
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("AFTER_LOADING");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any());
}
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
/** 资料上传必须在装车后且属于绑定仓,平台只读权限不能保存照片。 */
@Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception {
authorize();var f=new Fixture();
assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("装车完成后");
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThatThrownBy(()->f.documents.get(new Actor("picker",ActorMode.PDA,"99"),f.number)).hasMessageContaining("当前绑定仓");
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,PhotoSlot.FUEL_START,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("reader","","platform:overseas-warehouse:dispatch:list"));
assertThat(f.documents.get(f.f.actor,f.number).editable()).isFalse();
assertThatThrownBy(()->f.bind(0,f.f.actor)).hasMessageContaining("无装车资料操作权限");
}
/** 两端同版本竞争第五张,仅一个绑定和一次真实派送成功。 */
@Test void concurrentFifthPhotoCannotDispatchTwice() throws Exception {
authorize();var f=new Fixture();f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
for(int i=0;i<4;i++)f.bind(i,f.f.actor);
var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var results=new ArrayList<Future<Boolean>>();
for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{
authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor;
f.f.tx.execute(s->f.documents.bind(actor,f.number,PhotoSlot.RECEIPT,new BindPhoto("4",key,"1004")));return true;
}catch(com.baiyue.common.core.exception.BizException expected){return false;}
finally {SecurityContextHolder.clearContext();}
}));
start.countDown();assertThat(List.of(results.get(0).get(25,TimeUnit.SECONDS),results.get(1).get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();}
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5);
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
}
/** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest;
final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest;
final List<LoadingMeasurement> measurements;
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
Fixture() throws Exception {
@@ -127,6 +177,21 @@ class DispatchLoadingDeparturePersistenceTest {
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
f.session.getConfiguration().addMapper(OwnedBusinessMapper.class);
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String suffix:List.of("record","command","history")) {
var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema);
assertThat(matcher.find()).isTrue();
f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"));
}
var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class));
var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class);
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z"));
documents=new DispatchLoadingDocumentService(f.service,service,records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
}
/** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */
DispatchLoadingDocumentService.Documents bind(int index,Actor actor) {
return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index))));
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}