From 0c6c55f6f1fa39c717e3dbb36f6f3e59ada3e697 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 17:08:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch-loading-documents-v1.yaml | 393 ++++++++++++++++++ .../openapi/overseas-business-v1.yaml | 2 +- .../openapi/overseas-pda-operations-v1.yaml | 2 +- ...spatchLoadingDeparturePersistenceTest.java | 105 ++++- 4 files changed, 480 insertions(+), 22 deletions(-) create mode 100644 lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml new file mode 100644 index 000000000..23c86c5b7 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -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 diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f6ffb7d10..95e6679bb 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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: diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 3804a284f..bf9062af3 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -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: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index b2df98597..36e5391d6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -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>(); + 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 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,"实装发车");}