优化
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:
|
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||||
post:
|
post:
|
||||||
summary: 确认装车完成并自动进入派送中
|
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:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ paths:
|
|||||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||||
post:
|
post:
|
||||||
summary: 确认装车完成并自动进入派送中
|
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:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
+85
-20
@@ -1,6 +1,14 @@
|
|||||||
package com.baiyue.lastleg.dispatch.service;
|
package com.baiyue.lastleg.dispatch.service;
|
||||||
|
|
||||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
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.pda.service.DispatchPdaWorkflowService;
|
||||||
import com.baiyue.lastleg.dispatch.port.*;
|
import com.baiyue.lastleg.dispatch.port.*;
|
||||||
import com.baiyue.lastleg.dispatch.repository.*;
|
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.Mockito.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
|
||||||
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
|
/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
|
||||||
class DispatchLoadingDeparturePersistenceTest {
|
class DispatchLoadingDeparturePersistenceTest {
|
||||||
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
|
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
|
||||||
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
|
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
|
||||||
|
|
||||||
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
|
/** 装车完成不发车,第五张保存自动发车,旧装车键保持原装车后结果。 */
|
||||||
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
|
@Test void loadingWaitsForFivePhotosThenDepartsOnce() throws Exception {
|
||||||
authorize();var f=new Fixture();
|
authorize();var f=new Fixture();
|
||||||
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
|
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.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
|
||||||
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
|
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_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.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 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 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")));
|
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(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());
|
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);
|
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("版本或审核阶段");
|
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();
|
authorize();
|
||||||
for(boolean failPreparation:List.of(true,false)) {
|
for(boolean failPreparation:List.of(true,false)) {
|
||||||
var f=new Fixture();
|
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());
|
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
|
||||||
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
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);
|
assertThatThrownBy(()->f.bind(4,f.f.actor)).isInstanceOf(IllegalStateException.class);
|
||||||
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
|
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("AFTER_LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version+1);
|
||||||
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
|
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_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_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 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");
|
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);
|
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("真实装载清单已变化");
|
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
|
||||||
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
|
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"));
|
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("装车完成权限");
|
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
|
/** 同一版本后台/PDA同时确认只有一次装车提交,资料未齐不发车。 */
|
||||||
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
|
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
|
||||||
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
|
||||||
try {
|
try {
|
||||||
@@ -81,20 +99,52 @@ class DispatchLoadingDeparturePersistenceTest {
|
|||||||
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
|
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);
|
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||||
} finally {workers.shutdownNow();}
|
} finally {workers.shutdownNow();}
|
||||||
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
|
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("AFTER_LOADING");
|
||||||
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
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","",
|
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,派送提供者受控写入真实子单来验证共同回滚。 */
|
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
|
||||||
private static final class Fixture {
|
private static final class Fixture {
|
||||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||||
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
|
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
|
||||||
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.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;
|
final List<LoadingMeasurement> measurements;
|
||||||
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
|
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
|
||||||
Fixture() throws Exception {
|
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 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);
|
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);
|
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,"实装发车");}
|
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
|
||||||
|
|||||||
Reference in New Issue
Block a user