优化
This commit is contained in:
@@ -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:
|
||||
|
||||
+85
-20
@@ -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,"实装发车");}
|
||||
|
||||
Reference in New Issue
Block a user